Compare commits
	
		
			12 Commits
		
	
	
		
			047cb9dc0d
			...
			3.0.0+109
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fe8640a6db | |||
| ff475d43dd | |||
| 9e8f6d57df | |||
| 79227a12e2 | |||
| a23dcfe702 | |||
| 243ecb3f71 | |||
| b8dec9f798 | |||
| 536375729f | |||
| 5939a1dc5b | |||
| 9d115a5712 | |||
| f511612a53 | |||
| 180fbcc558 | 
							
								
								
									
										5
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/build.yml
									
									
									
									
										vendored
									
									
								
							| @@ -3,7 +3,7 @@ name: Build Release | |||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     tags: |     tags: | ||||||
|       - '*' |       - "*" | ||||||
|   workflow_dispatch: |   workflow_dispatch: | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
| @@ -59,6 +59,7 @@ jobs: | |||||||
|           sudo apt-get install -y libnotify-dev |           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 libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev | ||||||
|           sudo apt-get install -y gstreamer-1.0 |           sudo apt-get install -y gstreamer-1.0 | ||||||
|  |           sudo apt-get install -y libsecret-1 | ||||||
|       - run: flutter pub get |       - run: flutter pub get | ||||||
|       - run: flutter build linux |       - run: flutter build linux | ||||||
|       - name: Archive production artifacts |       - name: Archive production artifacts | ||||||
| @@ -80,4 +81,4 @@ jobs: | |||||||
|         uses: actions/upload-artifact@v4 |         uses: actions/upload-artifact@v4 | ||||||
|         with: |         with: | ||||||
|           name: build-output-linux-appimage |           name: build-output-linux-appimage | ||||||
|           path: './*.AppImage*' |           path: "./*.AppImage*" | ||||||
|   | |||||||
| @@ -57,6 +57,9 @@ android { | |||||||
|  |  | ||||||
| dependencies { | dependencies { | ||||||
|     implementation("com.google.android.material:material:1.12.0") |     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 { | flutter { | ||||||
|   | |||||||
| @@ -46,12 +46,37 @@ | |||||||
|             <intent-filter> |             <intent-filter> | ||||||
|                 <action android:name="android.intent.action.SEND" /> |                 <action android:name="android.intent.action.SEND" /> | ||||||
|                 <category android:name="android.intent.category.DEFAULT" /> |                 <category android:name="android.intent.category.DEFAULT" /> | ||||||
|                 <data android:mimeType="*/*" /> |                 <data android:mimeType="image/*" /> | ||||||
|             </intent-filter> |             </intent-filter> | ||||||
|             <intent-filter> |             <intent-filter> | ||||||
|                 <action android:name="android.intent.action.SEND_MULTIPLE" /> |                 <action android:name="android.intent.action.SEND_MULTIPLE" /> | ||||||
|                 <category android:name="android.intent.category.DEFAULT" /> |                 <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> |             </intent-filter> | ||||||
|         </activity> |         </activity> | ||||||
|  |  | ||||||
| @@ -70,6 +95,19 @@ | |||||||
|             </intent-filter> |             </intent-filter> | ||||||
|         </activity> |         </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 |         <provider | ||||||
|             android:name="androidx.core.content.FileProvider" |             android:name="androidx.core.content.FileProvider" | ||||||
|             android:authorities="dev.solsynth.solian.provider" |             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()) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -98,7 +98,8 @@ | |||||||
|   "explore": "Explore", |   "explore": "Explore", | ||||||
|   "exploreFilterSubscriptions": "Subscriptions", |   "exploreFilterSubscriptions": "Subscriptions", | ||||||
|   "exploreFilterFriends": "Friends", |   "exploreFilterFriends": "Friends", | ||||||
|   "discoverCommunities": "Discover Communities", |   "discover": "Discover", | ||||||
|  |   "joinRealm": "Join Realm", | ||||||
|   "account": "Account", |   "account": "Account", | ||||||
|   "name": "Name", |   "name": "Name", | ||||||
|   "slug": "Slug", |   "slug": "Slug", | ||||||
| @@ -406,15 +407,15 @@ | |||||||
|   "lastActiveAt": "Last active at {}", |   "lastActiveAt": "Last active at {}", | ||||||
|   "authDeviceLogout": "Logout", |   "authDeviceLogout": "Logout", | ||||||
|   "authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.", |   "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", |   "authDeviceEditLabel": "Edit Label", | ||||||
|   "authDeviceLabelTitle": "Edit Device Label", |   "authDeviceLabelTitle": "Edit Device Label", | ||||||
|   "authDeviceLabelHint": "Enter a name for this device", |   "authDeviceLabelHint": "Enter a name for this device", | ||||||
|   "authDeviceSwipeEditHint": "Swipe left to edit label", |   "authDeviceSwipeEditHint": "Swipe left to edit label", | ||||||
|   "authDeviceSwipeLogoutHint": "Swipe right to logout device", |   "authDeviceSwipeLogoutHint": "Swipe right to logout device", | ||||||
|   "typingHint": { |  | ||||||
|     "one": "{} is typing...", |  | ||||||
|     "other": "{} are typing..." |  | ||||||
|   }, |  | ||||||
|   "settingsAppearance": "Appearance", |   "settingsAppearance": "Appearance", | ||||||
|   "settingsServer": "Server", |   "settingsServer": "Server", | ||||||
|   "settingsBehavior": "Behavior", |   "settingsBehavior": "Behavior", | ||||||
| @@ -620,5 +621,13 @@ | |||||||
|   "tags": "Tags", |   "tags": "Tags", | ||||||
|   "tagsHint": "Enter tags, separated by commas", |   "tagsHint": "Enter tags, separated by commas", | ||||||
|   "categories": "Categories", |   "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_FILE = SolianShareExtension/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | ||||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| @@ -900,7 +900,7 @@ | |||||||
| 				INFOPLIST_FILE = SolianShareExtension/Info.plist; | 				INFOPLIST_FILE = SolianShareExtension/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | ||||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| @@ -940,7 +940,7 @@ | |||||||
| 				INFOPLIST_FILE = SolianShareExtension/Info.plist; | 				INFOPLIST_FILE = SolianShareExtension/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | 				INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension; | ||||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | 				IPHONEOS_DEPLOYMENT_TARGET = 13.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| @@ -979,7 +979,7 @@ | |||||||
| 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | ||||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| @@ -1021,7 +1021,7 @@ | |||||||
| 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | ||||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
| @@ -1060,7 +1060,7 @@ | |||||||
| 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | 				INFOPLIST_FILE = SolianNotificationService/Info.plist; | ||||||
| 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | 				INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService; | ||||||
| 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | 				INFOPLIST_KEY_NSHumanReadableCopyright = ""; | ||||||
| 				IPHONEOS_DEPLOYMENT_TARGET = 18.5; | 				IPHONEOS_DEPLOYMENT_TARGET = 15.0; | ||||||
| 				LD_RUNPATH_SEARCH_PATHS = ( | 				LD_RUNPATH_SEARCH_PATHS = ( | ||||||
| 					"$(inherited)", | 					"$(inherited)", | ||||||
| 					"@executable_path/Frameworks", | 					"@executable_path/Frameworks", | ||||||
|   | |||||||
| @@ -11,6 +11,21 @@ import UIKit | |||||||
|     ) -> Bool { |     ) -> Bool { | ||||||
|         UNUserNotificationCenter.current().delegate = notifyDelegate |         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) |         GeneratedPluginRegistrant.register(with: self) | ||||||
|         return super.application(application, didFinishLaunchingWithOptions: launchOptions) |         return super.application(application, didFinishLaunchingWithOptions: launchOptions) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -10,40 +10,51 @@ import Alamofire | |||||||
|  |  | ||||||
| class NotifyDelegate: UIResponder, UNUserNotificationCenterDelegate { | class NotifyDelegate: UIResponder, UNUserNotificationCenterDelegate { | ||||||
|     func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { |     func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { | ||||||
|         if let textResponse = response as? UNTextInputNotificationResponse { |         guard let textResponse = response as? UNTextInputNotificationResponse else { | ||||||
|             let content = response.notification.request.content |             completionHandler() | ||||||
|             guard let metadata = content.userInfo["meta"] as? [AnyHashable: Any] else { |             return | ||||||
|                 return |         } | ||||||
|             } |  | ||||||
|              |         let content = response.notification.request.content | ||||||
|             var token: String? = UserDefaults.standard.getFlutterToken() |          | ||||||
|             if token == nil { |         // Only handle replies for new messages | ||||||
|                 return |         guard let notificationType = content.userInfo["type"] as? String, notificationType == "messages.new" else { | ||||||
|             } |             completionHandler() | ||||||
|              |             return | ||||||
|             let serverUrl = UserDefaults.standard.getServerUrl() |         } | ||||||
|             let url = "\(serverUrl)/chat/\(metadata["room_id"] ?? "")/messages" |  | ||||||
|              |         guard let metadata = content.userInfo["meta"] as? [AnyHashable: Any] else { | ||||||
|             let parameters: [String: Any?] = [ |             completionHandler() | ||||||
|                 "content": textResponse.userText, |             return | ||||||
|                 "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 |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|         } |         } | ||||||
|          |          | ||||||
|         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 pfpIdentifier = meta["pfp"] as? String | ||||||
|          |          | ||||||
|         let replyableMessageCategory = UNNotificationCategory( |         content.categoryIdentifier = "REPLYABLE_MESSAGE" | ||||||
|             identifier: content.categoryIdentifier, |  | ||||||
|             actions: [ |  | ||||||
|                 UNTextInputNotificationAction( |  | ||||||
|                     identifier: "reply_action", |  | ||||||
|                     title: "Reply", |  | ||||||
|                     options: [] |  | ||||||
|                 ), |  | ||||||
|             ], |  | ||||||
|             intentIdentifiers: [], |  | ||||||
|             options: [] |  | ||||||
|         ) |  | ||||||
|          |  | ||||||
|         UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory]) |  | ||||||
|         content.categoryIdentifier = replyableMessageCategory.identifier |  | ||||||
|          |          | ||||||
|         let metaCopy = meta as? [String: Any] ?? [:] |         let metaCopy = meta as? [String: Any] ?? [:] | ||||||
|         let pfpUrl = pfpIdentifier != nil ? getAttachmentUrl(for: pfpIdentifier!) : nil |         let pfpUrl = pfpIdentifier != nil ? getAttachmentUrl(for: pfpIdentifier!) : nil | ||||||
|   | |||||||
| @@ -71,25 +71,32 @@ class MessageRepository { | |||||||
|     bool synced = false, |     bool synced = false, | ||||||
|   }) async { |   }) async { | ||||||
|     try { |     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( |       final localMessages = await _getCachedMessages( | ||||||
|         room.id, |         room.id, | ||||||
|         offset: offset, |         offset: offset, | ||||||
|         take: take, |         take: take, | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|       // If it already synced with the remote, skip this |       // If local cache has messages, return them. This is the common case for scrolling up. | ||||||
|       if (offset == 0 && !synced) { |       if (localMessages.isNotEmpty) { | ||||||
|         // Fetch latest messages |         return localMessages; | ||||||
|         _fetchAndCacheMessages(room.id, offset: offset, take: take); |  | ||||||
|  |  | ||||||
|         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); |       return await _fetchAndCacheMessages(room.id, offset: offset, take: take); | ||||||
|     } catch (e) { |     } 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( |       final localMessages = await _getCachedMessages( | ||||||
|         room.id, |         room.id, | ||||||
|         offset: offset, |         offset: offset, | ||||||
| @@ -117,24 +124,26 @@ class MessageRepository { | |||||||
|     final dbLocalMessages = |     final dbLocalMessages = | ||||||
|         dbMessages.map(_database.companionToMessage).toList(); |         dbMessages.map(_database.companionToMessage).toList(); | ||||||
|  |  | ||||||
|     // Combine with pending messages |     // Combine with pending messages for the first page | ||||||
|     final pendingForRoom = |     if (offset == 0) { | ||||||
|         pendingMessages.values.where((msg) => msg.roomId == roomId).toList(); |       final pendingForRoom = | ||||||
|  |           pendingMessages.values.where((msg) => msg.roomId == roomId).toList(); | ||||||
|  |  | ||||||
|     // Sort by timestamp descending (newest first) |       final allMessages = [...pendingForRoom, ...dbLocalMessages]; | ||||||
|     final allMessages = [...pendingForRoom, ...dbLocalMessages]; |       allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); | ||||||
|     allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); |  | ||||||
|  |  | ||||||
|     // Apply pagination |       // Remove duplicates by ID, preserving the order | ||||||
|     if (offset >= allMessages.length) { |       final uniqueMessages = <LocalChatMessage>[]; | ||||||
|       return []; |       final seenIds = <String>{}; | ||||||
|  |       for (final message in allMessages) { | ||||||
|  |         if (seenIds.add(message.id)) { | ||||||
|  |           uniqueMessages.add(message); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       return uniqueMessages; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     final end = |     return dbLocalMessages; | ||||||
|         (offset + take) > allMessages.length |  | ||||||
|             ? allMessages.length |  | ||||||
|             : (offset + take); |  | ||||||
|     return allMessages.sublist(offset, end); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<List<LocalChatMessage>> _fetchAndCacheMessages( |   Future<List<LocalChatMessage>> _fetchAndCacheMessages( | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart'; | |||||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | import 'package:firebase_messaging/firebase_messaging.dart'; | ||||||
| import 'package:flutter/foundation.dart'; | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:image_picker_android/image_picker_android.dart'; | import 'package:image_picker_android/image_picker_android.dart'; | ||||||
| @@ -29,6 +30,12 @@ import 'package:image_picker_platform_interface/image_picker_platform_interface. | |||||||
| import 'package:flutter_native_splash/flutter_native_splash.dart'; | import 'package:flutter_native_splash/flutter_native_splash.dart'; | ||||||
| import 'package:url_launcher/url_launcher_string.dart'; | import 'package:url_launcher/url_launcher_string.dart'; | ||||||
|  |  | ||||||
|  | @pragma('vm:entry-point') | ||||||
|  | Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { | ||||||
|  |   await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); | ||||||
|  |   log('Handling a background message: ${message.messageId}'); | ||||||
|  | } | ||||||
|  |  | ||||||
| void main() async { | void main() async { | ||||||
|   final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); |   final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); | ||||||
|   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { |   if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { | ||||||
| @@ -43,6 +50,7 @@ void main() async { | |||||||
|     await Firebase.initializeApp( |     await Firebase.initializeApp( | ||||||
|       options: DefaultFirebaseOptions.currentPlatform, |       options: DefaultFirebaseOptions.currentPlatform, | ||||||
|     ); |     ); | ||||||
|  |     FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); | ||||||
|     log("[SplashScreen] Firebase is ready!"); |     log("[SplashScreen] Firebase is ready!"); | ||||||
|   } catch (err) { |   } catch (err) { | ||||||
|     showErrorAlert(err); |     showErrorAlert(err); | ||||||
| @@ -151,17 +159,52 @@ class IslandApp extends HookConsumerWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     useEffect(() { |     useEffect(() { | ||||||
|       Future(() async { |       const channel = MethodChannel('dev.solsynth.solian/notifications'); | ||||||
|         RemoteMessage? initialMessage = |  | ||||||
|             await FirebaseMessaging.instance.getInitialMessage(); |  | ||||||
|         if (initialMessage != null) { |  | ||||||
|           handleMessage(initialMessage); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         FirebaseMessaging.onMessageOpenedApp.listen(handleMessage); |       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); | ||||||
|  |         } | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       return null; |       // When the app is opened from a terminated state. | ||||||
|  |       FirebaseMessaging.instance.getInitialMessage().then((message) { | ||||||
|  |         if (message != null) { | ||||||
|  |           handleMessage(message); | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       // When the app is in the background and opened. | ||||||
|  |       final onMessageOpenedAppSubscription = FirebaseMessaging | ||||||
|  |           .onMessageOpenedApp | ||||||
|  |           .listen(handleMessage); | ||||||
|  |  | ||||||
|  |       // When the app is in the foreground. | ||||||
|  |       final onMessageSubscription = FirebaseMessaging.onMessage.listen(( | ||||||
|  |         message, | ||||||
|  |       ) { | ||||||
|  |         log('Foreground message received: ${message.messageId}'); | ||||||
|  |         handleMessage(message); | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       return () { | ||||||
|  |         onMessageOpenedAppSubscription.cancel(); | ||||||
|  |         onMessageSubscription.cancel(); | ||||||
|  |       }; | ||||||
|     }, []); |     }, []); | ||||||
|  |  | ||||||
|     useEffect(() { |     useEffect(() { | ||||||
| @@ -185,7 +228,7 @@ class IslandApp extends HookConsumerWidget { | |||||||
|     }, []); |     }, []); | ||||||
|  |  | ||||||
|     final router = ref.watch(routerProvider); |     final router = ref.watch(routerProvider); | ||||||
|      |  | ||||||
|     return MaterialApp.router( |     return MaterialApp.router( | ||||||
|       theme: theme?.light, |       theme: theme?.light, | ||||||
|       darkTheme: theme?.dark, |       darkTheme: theme?.dark, | ||||||
| @@ -204,9 +247,8 @@ class IslandApp extends HookConsumerWidget { | |||||||
|           initialEntries: [ |           initialEntries: [ | ||||||
|             OverlayEntry( |             OverlayEntry( | ||||||
|               builder: |               builder: | ||||||
|                   (_) => WindowScaffold( |                   (_) => | ||||||
|                     child: child ?? const SizedBox.shrink(), |                       WindowScaffold(child: child ?? const SizedBox.shrink()), | ||||||
|                   ), |  | ||||||
|             ), |             ), | ||||||
|           ], |           ], | ||||||
|         ); |         ); | ||||||
|   | |||||||
| @@ -13,7 +13,8 @@ sealed class SnChatRoom with _$SnChatRoom { | |||||||
|     required String? name, |     required String? name, | ||||||
|     required String? description, |     required String? description, | ||||||
|     required int type, |     required int type, | ||||||
|     required bool isPublic, |     @Default(false) bool isPublic, | ||||||
|  |     @Default(false) bool isCommunity, | ||||||
|     required SnCloudFile? picture, |     required SnCloudFile? picture, | ||||||
|     required SnCloudFile? background, |     required SnCloudFile? background, | ||||||
|     required String? realmId, |     required String? realmId, | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ T _$identity<T>(T value) => value; | |||||||
| /// @nodoc | /// @nodoc | ||||||
| mixin _$SnChatRoom { | mixin _$SnChatRoom { | ||||||
|  |  | ||||||
|  String get id; String? get name; String? get description; int get type; bool get isPublic; SnCloudFile? get picture; SnCloudFile? get background; String? get realmId; SnRealm? get realm; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; List<SnChatMember>? get members; |  String get id; String? get name; String? get description; int get type; bool get isPublic; bool get isCommunity; SnCloudFile? get picture; SnCloudFile? get background; String? get realmId; SnRealm? get realm; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; List<SnChatMember>? get members; | ||||||
| /// Create a copy of SnChatRoom | /// Create a copy of SnChatRoom | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @@ -29,16 +29,16 @@ $SnChatRoomCopyWith<SnChatRoom> get copyWith => _$SnChatRoomCopyWithImpl<SnChatR | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other.members, members)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.isCommunity, isCommunity) || other.isCommunity == isCommunity)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other.members, members)); | ||||||
| } | } | ||||||
|  |  | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(members)); | int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,isCommunity,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(members)); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)'; |   return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, isCommunity: $isCommunity, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -49,7 +49,7 @@ abstract mixin class $SnChatRoomCopyWith<$Res>  { | |||||||
|   factory $SnChatRoomCopyWith(SnChatRoom value, $Res Function(SnChatRoom) _then) = _$SnChatRoomCopyWithImpl; |   factory $SnChatRoomCopyWith(SnChatRoom value, $Res Function(SnChatRoom) _then) = _$SnChatRoomCopyWithImpl; | ||||||
| @useResult | @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String id, String? name, String? description, int type, bool isPublic, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members |  String id, String? name, String? description, int type, bool isPublic, bool isCommunity, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -66,13 +66,14 @@ class _$SnChatRoomCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of SnChatRoom | /// Create a copy of SnChatRoom | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) { | @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? isCommunity = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) { | ||||||
|   return _then(_self.copyWith( |   return _then(_self.copyWith( | ||||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||||
| as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||||
| as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable | as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable | ||||||
|  | as bool,isCommunity: null == isCommunity ? _self.isCommunity : isCommunity // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable | as bool,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable | ||||||
| as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable | as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable | ||||||
| as SnCloudFile?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable | as SnCloudFile?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable | ||||||
| @@ -128,14 +129,15 @@ $SnRealmCopyWith<$Res>? get realm { | |||||||
| @JsonSerializable() | @JsonSerializable() | ||||||
|  |  | ||||||
| class _SnChatRoom implements SnChatRoom { | class _SnChatRoom implements SnChatRoom { | ||||||
|   const _SnChatRoom({required this.id, required this.name, required this.description, required this.type, required this.isPublic, required this.picture, required this.background, required this.realmId, required this.realm, required this.createdAt, required this.updatedAt, required this.deletedAt, required final  List<SnChatMember>? members}): _members = members; |   const _SnChatRoom({required this.id, required this.name, required this.description, required this.type, this.isPublic = false, this.isCommunity = false, required this.picture, required this.background, required this.realmId, required this.realm, required this.createdAt, required this.updatedAt, required this.deletedAt, required final  List<SnChatMember>? members}): _members = members; | ||||||
|   factory _SnChatRoom.fromJson(Map<String, dynamic> json) => _$SnChatRoomFromJson(json); |   factory _SnChatRoom.fromJson(Map<String, dynamic> json) => _$SnChatRoomFromJson(json); | ||||||
|  |  | ||||||
| @override final  String id; | @override final  String id; | ||||||
| @override final  String? name; | @override final  String? name; | ||||||
| @override final  String? description; | @override final  String? description; | ||||||
| @override final  int type; | @override final  int type; | ||||||
| @override final  bool isPublic; | @override@JsonKey() final  bool isPublic; | ||||||
|  | @override@JsonKey() final  bool isCommunity; | ||||||
| @override final  SnCloudFile? picture; | @override final  SnCloudFile? picture; | ||||||
| @override final  SnCloudFile? background; | @override final  SnCloudFile? background; | ||||||
| @override final  String? realmId; | @override final  String? realmId; | ||||||
| @@ -166,16 +168,16 @@ Map<String, dynamic> toJson() { | |||||||
|  |  | ||||||
| @override | @override | ||||||
| bool operator ==(Object other) { | bool operator ==(Object other) { | ||||||
|   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other._members, _members)); |   return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.isCommunity, isCommunity) || other.isCommunity == isCommunity)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other._members, _members)); | ||||||
| } | } | ||||||
|  |  | ||||||
| @JsonKey(includeFromJson: false, includeToJson: false) | @JsonKey(includeFromJson: false, includeToJson: false) | ||||||
| @override | @override | ||||||
| int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(_members)); | int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,isCommunity,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(_members)); | ||||||
|  |  | ||||||
| @override | @override | ||||||
| String toString() { | String toString() { | ||||||
|   return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)'; |   return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, isCommunity: $isCommunity, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)'; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -186,7 +188,7 @@ abstract mixin class _$SnChatRoomCopyWith<$Res> implements $SnChatRoomCopyWith<$ | |||||||
|   factory _$SnChatRoomCopyWith(_SnChatRoom value, $Res Function(_SnChatRoom) _then) = __$SnChatRoomCopyWithImpl; |   factory _$SnChatRoomCopyWith(_SnChatRoom value, $Res Function(_SnChatRoom) _then) = __$SnChatRoomCopyWithImpl; | ||||||
| @override @useResult | @override @useResult | ||||||
| $Res call({ | $Res call({ | ||||||
|  String id, String? name, String? description, int type, bool isPublic, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members |  String id, String? name, String? description, int type, bool isPublic, bool isCommunity, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -203,13 +205,14 @@ class __$SnChatRoomCopyWithImpl<$Res> | |||||||
|  |  | ||||||
| /// Create a copy of SnChatRoom | /// Create a copy of SnChatRoom | ||||||
| /// with the given fields replaced by the non-null parameter values. | /// with the given fields replaced by the non-null parameter values. | ||||||
| @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) { | @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? isCommunity = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) { | ||||||
|   return _then(_SnChatRoom( |   return _then(_SnChatRoom( | ||||||
| id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable | ||||||
| as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable | ||||||
| as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable | ||||||
| as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable | as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable | ||||||
|  | as bool,isCommunity: null == isCommunity ? _self.isCommunity : isCommunity // ignore: cast_nullable_to_non_nullable | ||||||
| as bool,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable | as bool,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable | ||||||
| as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable | as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable | ||||||
| as SnCloudFile?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable | as SnCloudFile?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable | ||||||
|   | |||||||
| @@ -11,7 +11,8 @@ _SnChatRoom _$SnChatRoomFromJson(Map<String, dynamic> json) => _SnChatRoom( | |||||||
|   name: json['name'] as String?, |   name: json['name'] as String?, | ||||||
|   description: json['description'] as String?, |   description: json['description'] as String?, | ||||||
|   type: (json['type'] as num).toInt(), |   type: (json['type'] as num).toInt(), | ||||||
|   isPublic: json['is_public'] as bool, |   isPublic: json['is_public'] as bool? ?? false, | ||||||
|  |   isCommunity: json['is_community'] as bool? ?? false, | ||||||
|   picture: |   picture: | ||||||
|       json['picture'] == null |       json['picture'] == null | ||||||
|           ? null |           ? null | ||||||
| @@ -44,6 +45,7 @@ Map<String, dynamic> _$SnChatRoomToJson(_SnChatRoom instance) => | |||||||
|       'description': instance.description, |       'description': instance.description, | ||||||
|       'type': instance.type, |       'type': instance.type, | ||||||
|       'is_public': instance.isPublic, |       'is_public': instance.isPublic, | ||||||
|  |       'is_community': instance.isCommunity, | ||||||
|       'picture': instance.picture?.toJson(), |       'picture': instance.picture?.toJson(), | ||||||
|       'background': instance.background?.toJson(), |       'background': instance.background?.toJson(), | ||||||
|       'realm_id': instance.realmId, |       'realm_id': instance.realmId, | ||||||
|   | |||||||
| @@ -10,8 +10,8 @@ sealed class SnRealm with _$SnRealm { | |||||||
|   const factory SnRealm({ |   const factory SnRealm({ | ||||||
|     required String id, |     required String id, | ||||||
|     required String slug, |     required String slug, | ||||||
|     required String name, |     @Default('') String name, | ||||||
|     required String description, |     @Default('') String description, | ||||||
|     required String? verifiedAs, |     required String? verifiedAs, | ||||||
|     required DateTime? verifiedAt, |     required DateTime? verifiedAt, | ||||||
|     required bool isCommunity, |     required bool isCommunity, | ||||||
|   | |||||||
| @@ -117,13 +117,13 @@ $SnCloudFileCopyWith<$Res>? get background { | |||||||
| @JsonSerializable() | @JsonSerializable() | ||||||
|  |  | ||||||
| class _SnRealm implements SnRealm { | class _SnRealm implements SnRealm { | ||||||
|   const _SnRealm({required this.id, required this.slug, required this.name, required this.description, required this.verifiedAs, required this.verifiedAt, required this.isCommunity, required this.isPublic, required this.picture, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); |   const _SnRealm({required this.id, required this.slug, this.name = '', this.description = '', required this.verifiedAs, required this.verifiedAt, required this.isCommunity, required this.isPublic, required this.picture, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); | ||||||
|   factory _SnRealm.fromJson(Map<String, dynamic> json) => _$SnRealmFromJson(json); |   factory _SnRealm.fromJson(Map<String, dynamic> json) => _$SnRealmFromJson(json); | ||||||
|  |  | ||||||
| @override final  String id; | @override final  String id; | ||||||
| @override final  String slug; | @override final  String slug; | ||||||
| @override final  String name; | @override@JsonKey() final  String name; | ||||||
| @override final  String description; | @override@JsonKey() final  String description; | ||||||
| @override final  String? verifiedAs; | @override final  String? verifiedAs; | ||||||
| @override final  DateTime? verifiedAt; | @override final  DateTime? verifiedAt; | ||||||
| @override final  bool isCommunity; | @override final  bool isCommunity; | ||||||
|   | |||||||
| @@ -9,8 +9,8 @@ part of 'realm.dart'; | |||||||
| _SnRealm _$SnRealmFromJson(Map<String, dynamic> json) => _SnRealm( | _SnRealm _$SnRealmFromJson(Map<String, dynamic> json) => _SnRealm( | ||||||
|   id: json['id'] as String, |   id: json['id'] as String, | ||||||
|   slug: json['slug'] as String, |   slug: json['slug'] as String, | ||||||
|   name: json['name'] as String, |   name: json['name'] as String? ?? '', | ||||||
|   description: json['description'] as String, |   description: json['description'] as String? ?? '', | ||||||
|   verifiedAs: json['verified_as'] as String?, |   verifiedAs: json['verified_as'] as String?, | ||||||
|   verifiedAt: |   verifiedAt: | ||||||
|       json['verified_at'] == null |       json['verified_at'] == null | ||||||
|   | |||||||
| @@ -32,7 +32,6 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { | |||||||
|     state = const AsyncValue.data(null); |     state = const AsyncValue.data(null); | ||||||
|     final prefs = _ref.read(sharedPreferencesProvider); |     final prefs = _ref.read(sharedPreferencesProvider); | ||||||
|     await prefs.remove(kTokenPairStoreKey); |     await prefs.remove(kTokenPairStoreKey); | ||||||
|     _ref.invalidate(userInfoProvider); |  | ||||||
|     _ref.invalidate(tokenProvider); |     _ref.invalidate(tokenProvider); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										111
									
								
								lib/route.dart
									
									
									
									
									
								
							
							
						
						
									
										111
									
								
								lib/route.dart
									
									
									
									
									
								
							| @@ -31,6 +31,7 @@ import 'package:island/screens/settings.dart'; | |||||||
| import 'package:island/screens/realm/realms.dart'; | import 'package:island/screens/realm/realms.dart'; | ||||||
| import 'package:island/screens/realm/detail.dart'; | import 'package:island/screens/realm/detail.dart'; | ||||||
| import 'package:island/screens/account/event_calendar.dart'; | import 'package:island/screens/account/event_calendar.dart'; | ||||||
|  | import 'package:island/screens/discovery/realms.dart'; | ||||||
|  |  | ||||||
| // Shell route keys for nested navigation | // Shell route keys for nested navigation | ||||||
| final rootNavigatorKey = GlobalKey<NavigatorState>(); | final rootNavigatorKey = GlobalKey<NavigatorState>(); | ||||||
| @@ -52,7 +53,10 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|           // Standalone routes without bottom navigation |           // Standalone routes without bottom navigation | ||||||
|           GoRoute( |           GoRoute( | ||||||
|             path: '/posts/compose', |             path: '/posts/compose', | ||||||
|             builder: (context, state) => const PostComposeScreen(), |             builder: | ||||||
|  |                 (context, state) => PostComposeScreen( | ||||||
|  |                   initialState: state.extra as PostComposeInitialState?, | ||||||
|  |                 ), | ||||||
|           ), |           ), | ||||||
|           GoRoute( |           GoRoute( | ||||||
|             path: '/posts/:id/edit', |             path: '/posts/:id/edit', | ||||||
| @@ -75,44 +79,45 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|               return EventCalanderScreen(name: name); |               return EventCalanderScreen(name: name); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
|           GoRoute( |           ShellRoute( | ||||||
|             path: '/creators', |             builder: | ||||||
|             builder: (context, state) => const CreatorHubScreen(), |                 (context, state, child) => CreatorHubShellScreen(child: child), | ||||||
|             routes: [ |             routes: [ | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: ':name/posts', |                 path: '/creators', | ||||||
|  |                 builder: (context, state) => const CreatorHubScreen(), | ||||||
|  |               ), | ||||||
|  |               GoRoute( | ||||||
|  |                 path: '/creators/:name/posts', | ||||||
|                 builder: (context, state) { |                 builder: (context, state) { | ||||||
|                   final name = state.pathParameters['name']!; |                   final name = state.pathParameters['name']!; | ||||||
|                   return CreatorPostListScreen(pubName: name); |                   return CreatorPostListScreen(pubName: name); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: ':name/stickers', |                 path: '/creators/:name/stickers', | ||||||
|                 builder: (context, state) { |                 builder: (context, state) { | ||||||
|                   final name = state.pathParameters['name']!; |                   final name = state.pathParameters['name']!; | ||||||
|                   return StickersScreen(pubName: name); |                   return StickersScreen(pubName: name); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: ':name/stickers/new', |                 path: '/creators/:name/stickers/new', | ||||||
|                 builder: (context, state) { |                 builder: (context, state) { | ||||||
|                   final name = state.pathParameters['name']!; |                   final name = state.pathParameters['name']!; | ||||||
|                   return NewStickerPacksScreen(pubName: name); |                   return NewStickerPacksScreen(pubName: name); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: ':name/stickers/:packId/edit', |                 path: '/creators/:name/stickers/:packId/edit', | ||||||
|                 builder: (context, state) { |                 builder: (context, state) { | ||||||
|                   final name = state.pathParameters['name']!; |                   final name = state.pathParameters['name']!; | ||||||
|                   final packId = state.pathParameters['packId']!; |                   final packId = state.pathParameters['packId']!; | ||||||
|                   return EditStickerPacksScreen( |                   return EditStickerPacksScreen(pubName: name, packId: packId); | ||||||
|                     pubName: name, |  | ||||||
|                     packId: packId, |  | ||||||
|                   ); |  | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: ':name/stickers/:packId', |                 path: '/creators/:name/stickers/:packId', | ||||||
|                 builder: (context, state) { |                 builder: (context, state) { | ||||||
|                   final name = state.pathParameters['name']!; |                   final name = state.pathParameters['name']!; | ||||||
|                   final packId = state.pathParameters['packId']!; |                   final packId = state.pathParameters['packId']!; | ||||||
| @@ -120,14 +125,14 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: ':name/stickers/:packId/new', |                 path: '/creators/:name/stickers/:packId/new', | ||||||
|                 builder: (context, state) { |                 builder: (context, state) { | ||||||
|                   final packId = state.pathParameters['packId']!; |                   final packId = state.pathParameters['packId']!; | ||||||
|                   return NewStickersScreen(packId: packId); |                   return NewStickersScreen(packId: packId); | ||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: ':name/stickers/:packId/:id/edit', |                 path: '/creators/:name/stickers/:packId/:id/edit', | ||||||
|                 builder: (context, state) { |                 builder: (context, state) { | ||||||
|                   final packId = state.pathParameters['packId']!; |                   final packId = state.pathParameters['packId']!; | ||||||
|                   final id = state.pathParameters['id']!; |                   final id = state.pathParameters['id']!; | ||||||
| @@ -135,11 +140,11 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|                 }, |                 }, | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: 'new', |                 path: '/creators/new', | ||||||
|                 builder: (context, state) => const NewPublisherScreen(), |                 builder: (context, state) => const NewPublisherScreen(), | ||||||
|               ), |               ), | ||||||
|               GoRoute( |               GoRoute( | ||||||
|                 path: ':name/edit', |                 path: '/creators/:name/edit', | ||||||
|                 builder: (context, state) { |                 builder: (context, state) { | ||||||
|                   final name = state.pathParameters['name']!; |                   final name = state.pathParameters['name']!; | ||||||
|                   return EditPublisherScreen(name: name); |                   return EditPublisherScreen(name: name); | ||||||
| @@ -172,52 +177,64 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|             }, |             }, | ||||||
|             routes: [ |             routes: [ | ||||||
|               // Explore tab |               // Explore tab | ||||||
|               GoRoute( |               ShellRoute( | ||||||
|                 path: '/', |                 builder: | ||||||
|                 builder: (context, state) => const ExploreScreen(), |                     (context, state, child) => ExploreShellScreen(child: child), | ||||||
|                 routes: [ |                 routes: [ | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'posts/:id', |                     path: '/', | ||||||
|  |                     builder: (context, state) => const ExploreScreen(), | ||||||
|  |                   ), | ||||||
|  |                   GoRoute( | ||||||
|  |                     path: '/posts/:id', | ||||||
|                     builder: (context, state) { |                     builder: (context, state) { | ||||||
|                       final id = state.pathParameters['id']!; |                       final id = state.pathParameters['id']!; | ||||||
|                       return PostDetailScreen(id: id); |                       return PostDetailScreen(id: id); | ||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'publishers/:name', |                     path: '/publishers/:name', | ||||||
|                     builder: (context, state) { |                     builder: (context, state) { | ||||||
|                       final name = state.pathParameters['name']!; |                       final name = state.pathParameters['name']!; | ||||||
|                       return PublisherProfileScreen(name: name); |                       return PublisherProfileScreen(name: name); | ||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|  |                   GoRoute( | ||||||
|  |                     path: '/discovery/realms', | ||||||
|  |                     builder: (context, state) => const DiscoveryRealmsScreen(), | ||||||
|  |                   ), | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|  |  | ||||||
|               // Chat tab |               // Chat tab | ||||||
|               GoRoute( |               ShellRoute( | ||||||
|                 path: '/chat', |                 builder: | ||||||
|                 builder: (context, state) => const ChatListScreen(), |                     (context, state, child) => ChatShellScreen(child: child), | ||||||
|                 routes: [ |                 routes: [ | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: ':id', |                     path: '/chat', | ||||||
|  |                     builder: (context, state) => const ChatListScreen(), | ||||||
|  |                   ), | ||||||
|  |                   GoRoute( | ||||||
|  |                     path: '/chat/new', | ||||||
|  |                     builder: (context, state) => const NewChatScreen(), | ||||||
|  |                   ), | ||||||
|  |                   GoRoute( | ||||||
|  |                     path: '/chat/:id', | ||||||
|                     builder: (context, state) { |                     builder: (context, state) { | ||||||
|                       final id = state.pathParameters['id']!; |                       final id = state.pathParameters['id']!; | ||||||
|                       return ChatRoomScreen(id: id); |                       return ChatRoomScreen(id: id); | ||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'new', |                     path: '/chat/:id/edit', | ||||||
|                     builder: (context, state) => const NewChatScreen(), |  | ||||||
|                   ), |  | ||||||
|                   GoRoute( |  | ||||||
|                     path: ':id/edit', |  | ||||||
|                     builder: (context, state) { |                     builder: (context, state) { | ||||||
|                       final id = state.pathParameters['id']!; |                       final id = state.pathParameters['id']!; | ||||||
|                       return EditChatScreen(id: id); |                       return EditChatScreen(id: id); | ||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: ':id/detail', |                     path: '/chat/:id/detail', | ||||||
|                     builder: (context, state) { |                     builder: (context, state) { | ||||||
|                       final id = state.pathParameters['id']!; |                       final id = state.pathParameters['id']!; | ||||||
|                       return ChatDetailScreen(id: id); |                       return ChatDetailScreen(id: id); | ||||||
| @@ -227,9 +244,9 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|               ), |               ), | ||||||
|  |  | ||||||
|               // Realms tab |               // Realms tab | ||||||
|                GoRoute( |               GoRoute( | ||||||
|                  path: '/realms', |                 path: '/realms', | ||||||
|                  builder: (context, state) => const RealmListScreen(), |                 builder: (context, state) => const RealmListScreen(), | ||||||
|                 routes: [ |                 routes: [ | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'new', |                     path: 'new', | ||||||
| @@ -253,39 +270,43 @@ final routerProvider = Provider<GoRouter>((ref) { | |||||||
|               ), |               ), | ||||||
|  |  | ||||||
|               // Account tab |               // Account tab | ||||||
|               GoRoute( |               ShellRoute( | ||||||
|                 path: '/account', |                 builder: | ||||||
|                 builder: (context, state) => const AccountScreen(), |                     (context, state, child) => AccountShellScreen(child: child), | ||||||
|                 routes: [ |                 routes: [ | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'notifications', |                     path: '/account', | ||||||
|  |                     builder: (context, state) => const AccountScreen(), | ||||||
|  |                   ), | ||||||
|  |                   GoRoute( | ||||||
|  |                     path: '/account/notifications', | ||||||
|                     builder: (context, state) => const NotificationScreen(), |                     builder: (context, state) => const NotificationScreen(), | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'wallet', |                     path: '/account/wallet', | ||||||
|                     builder: (context, state) => const WalletScreen(), |                     builder: (context, state) => const WalletScreen(), | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'relationships', |                     path: '/account/relationships', | ||||||
|                     builder: (context, state) => const RelationshipScreen(), |                     builder: (context, state) => const RelationshipScreen(), | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: ':name', |                     path: '/account/:name', | ||||||
|                     builder: (context, state) { |                     builder: (context, state) { | ||||||
|                       final name = state.pathParameters['name']!; |                       final name = state.pathParameters['name']!; | ||||||
|                       return AccountProfileScreen(name: name); |                       return AccountProfileScreen(name: name); | ||||||
|                     }, |                     }, | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'me/update', |                     path: '/account/me/update', | ||||||
|                     builder: (context, state) => const UpdateProfileScreen(), |                     builder: (context, state) => const UpdateProfileScreen(), | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'me/leveling', |                     path: '/account/me/leveling', | ||||||
|                     builder: (context, state) => const LevelingScreen(), |                     builder: (context, state) => const LevelingScreen(), | ||||||
|                   ), |                   ), | ||||||
|                   GoRoute( |                   GoRoute( | ||||||
|                     path: 'settings', |                     path: '/account/settings', | ||||||
|                     builder: (context, state) => const AccountSettingsScreen(), |                     builder: (context, state) => const AccountSettingsScreen(), | ||||||
|                   ), |                   ), | ||||||
|                 ], |                 ], | ||||||
|   | |||||||
| @@ -143,7 +143,7 @@ class AccountScreen extends HookConsumerWidget { | |||||||
|                 progress: user.value!.profile.levelingProgress, |                 progress: user.value!.profile.levelingProgress, | ||||||
|               ), |               ), | ||||||
|               onTap: () { |               onTap: () { | ||||||
|                 context.push('/account/leveling'); |                 context.push('/account/me/leveling'); | ||||||
|               }, |               }, | ||||||
|             ).padding(horizontal: 12), |             ).padding(horizontal: 12), | ||||||
|             Row( |             Row( | ||||||
| @@ -200,7 +200,7 @@ class AccountScreen extends HookConsumerWidget { | |||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|               onTap: () { |               onTap: () { | ||||||
|                 context.push('/notification'); |                 context.push('/account/notifications'); | ||||||
|               }, |               }, | ||||||
|             ), |             ), | ||||||
|             ListTile( |             ListTile( | ||||||
| @@ -210,7 +210,7 @@ class AccountScreen extends HookConsumerWidget { | |||||||
|               contentPadding: EdgeInsets.symmetric(horizontal: 24), |               contentPadding: EdgeInsets.symmetric(horizontal: 24), | ||||||
|               title: Text('wallet').tr(), |               title: Text('wallet').tr(), | ||||||
|               onTap: () { |               onTap: () { | ||||||
|                 context.push('/wallet'); |                 context.push('/account/wallet'); | ||||||
|               }, |               }, | ||||||
|             ), |             ), | ||||||
|             ListTile( |             ListTile( | ||||||
|   | |||||||
| @@ -53,17 +53,21 @@ Future<List<SnAccountBadge>> accountBadges(Ref ref, String uname) async { | |||||||
|  |  | ||||||
| @riverpod | @riverpod | ||||||
| Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async { | Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async { | ||||||
|   final account = await ref.watch(accountProvider(uname).future); |   try { | ||||||
|   if (account.profile.background == null) return null; |     final account = await ref.watch(accountProvider(uname).future); | ||||||
|   final palette = await PaletteGenerator.fromImageProvider( |     if (account.profile.background == null) return null; | ||||||
|     CloudImageWidget.provider( |     final palette = await PaletteGenerator.fromImageProvider( | ||||||
|       fileId: account.profile.background!.id, |       CloudImageWidget.provider( | ||||||
|       serverUrl: ref.watch(serverUrlProvider), |         fileId: account.profile.background!.id, | ||||||
|     ), |         serverUrl: ref.watch(serverUrlProvider), | ||||||
|   ); |       ), | ||||||
|   final dominantColor = palette.dominantColor?.color; |     ); | ||||||
|   if (dominantColor == null) return null; |     final dominantColor = palette.dominantColor?.color; | ||||||
|   return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; |     if (dominantColor == null) return null; | ||||||
|  |     return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||||
|  |   } catch (_) { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @riverpod | @riverpod | ||||||
|   | |||||||
| @@ -215,6 +215,7 @@ class RelationshipScreen extends HookConsumerWidget { | |||||||
|     Future<void> addFriend() async { |     Future<void> addFriend() async { | ||||||
|       final result = await showModalBottomSheet( |       final result = await showModalBottomSheet( | ||||||
|         context: context, |         context: context, | ||||||
|  |         useRootNavigator: true, | ||||||
|         builder: (context) => AccountPickerSheet(), |         builder: (context) => AccountPickerSheet(), | ||||||
|       ); |       ); | ||||||
|       if (result == null) return; |       if (result == null) return; | ||||||
|   | |||||||
| @@ -186,7 +186,7 @@ class ChatShellScreen extends HookConsumerWidget { | |||||||
|         child: Row( |         child: Row( | ||||||
|           children: [ |           children: [ | ||||||
|             Flexible(flex: 2, child: ChatListScreen(isAside: true)), |             Flexible(flex: 2, child: ChatListScreen(isAside: true)), | ||||||
|             VerticalDivider(width: 1), |             const VerticalDivider(width: 1), | ||||||
|             Flexible(flex: 4, child: child), |             Flexible(flex: 4, child: child), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
| @@ -227,7 +227,8 @@ class ChatListScreen extends HookConsumerWidget { | |||||||
|     Future<void> createDirectMessage() async { |     Future<void> createDirectMessage() async { | ||||||
|       final result = await showModalBottomSheet( |       final result = await showModalBottomSheet( | ||||||
|         context: context, |         context: context, | ||||||
|         builder: (context) => AccountPickerSheet(), |         useRootNavigator: true, | ||||||
|  |         builder: (context) => const AccountPickerSheet(), | ||||||
|       ); |       ); | ||||||
|       if (result == null) return; |       if (result == null) return; | ||||||
|       final client = ref.read(apiClientProvider); |       final client = ref.read(apiClientProvider); | ||||||
| @@ -242,7 +243,7 @@ class ChatListScreen extends HookConsumerWidget { | |||||||
|     return AppScaffold( |     return AppScaffold( | ||||||
|       extendBody: false, // Prevent conflicts with tabs navigation |       extendBody: false, // Prevent conflicts with tabs navigation | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: Text('chat').tr(), |         title: const Text('chat').tr(), | ||||||
|         bottom: TabBar( |         bottom: TabBar( | ||||||
|           controller: tabController, |           controller: tabController, | ||||||
|           tabs: [ |           tabs: [ | ||||||
| @@ -296,7 +297,7 @@ class ChatListScreen extends HookConsumerWidget { | |||||||
|               showModalBottomSheet( |               showModalBottomSheet( | ||||||
|                 isScrollControlled: true, |                 isScrollControlled: true, | ||||||
|                 context: context, |                 context: context, | ||||||
|                 builder: (context) => _ChatInvitesSheet(), |                 builder: (context) => const _ChatInvitesSheet(), | ||||||
|               ); |               ); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
| @@ -307,13 +308,14 @@ class ChatListScreen extends HookConsumerWidget { | |||||||
|         onPressed: () { |         onPressed: () { | ||||||
|           showModalBottomSheet( |           showModalBottomSheet( | ||||||
|             context: context, |             context: context, | ||||||
|  |             useRootNavigator: true, | ||||||
|             builder: |             builder: | ||||||
|                 (context) => Column( |                 (context) => Column( | ||||||
|                   mainAxisSize: MainAxisSize.min, |                   mainAxisSize: MainAxisSize.min, | ||||||
|                   crossAxisAlignment: CrossAxisAlignment.stretch, |                   crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|                   children: [ |                   children: [ | ||||||
|                     ListTile( |                     ListTile( | ||||||
|                       title: Text('createChatRoom').tr(), |                       title: const Text('createChatRoom').tr(), | ||||||
|                       leading: const Icon(Symbols.add), |                       leading: const Icon(Symbols.add), | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         Navigator.pop(context); |                         Navigator.pop(context); | ||||||
| @@ -325,7 +327,7 @@ class ChatListScreen extends HookConsumerWidget { | |||||||
|                       }, |                       }, | ||||||
|                     ), |                     ), | ||||||
|                     ListTile( |                     ListTile( | ||||||
|                       title: Text('createDirectMessage').tr(), |                       title: const Text('createDirectMessage').tr(), | ||||||
|                       leading: const Icon(Symbols.person), |                       leading: const Icon(Symbols.person), | ||||||
|                       onTap: () { |                       onTap: () { | ||||||
|                         Navigator.pop(context); |                         Navigator.pop(context); | ||||||
| @@ -432,17 +434,31 @@ class ChatListScreen extends HookConsumerWidget { | |||||||
| @riverpod | @riverpod | ||||||
| Future<SnChatRoom?> chatroom(Ref ref, String? identifier) async { | Future<SnChatRoom?> chatroom(Ref ref, String? identifier) async { | ||||||
|   if (identifier == null) return null; |   if (identifier == null) return null; | ||||||
|   final client = ref.watch(apiClientProvider); |   try { | ||||||
|   final resp = await client.get('/chat/$identifier'); |     final client = ref.watch(apiClientProvider); | ||||||
|   return SnChatRoom.fromJson(resp.data); |     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 | @riverpod | ||||||
| Future<SnChatMember?> chatroomIdentity(Ref ref, String? identifier) async { | Future<SnChatMember?> chatroomIdentity(Ref ref, String? identifier) async { | ||||||
|   if (identifier == null) return null; |   if (identifier == null) return null; | ||||||
|   final client = ref.watch(apiClientProvider); |   try { | ||||||
|   final resp = await client.get('/chat/$identifier/members/me'); |     final client = ref.watch(apiClientProvider); | ||||||
|   return SnChatMember.fromJson(resp.data); |     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 { | class NewChatScreen extends StatelessWidget { | ||||||
| @@ -450,7 +466,7 @@ class NewChatScreen extends StatelessWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return EditChatScreen(); |     return const EditChatScreen(); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -468,6 +484,8 @@ class EditChatScreen extends HookConsumerWidget { | |||||||
|     final descriptionController = useTextEditingController(); |     final descriptionController = useTextEditingController(); | ||||||
|     final picture = useState<SnCloudFile?>(null); |     final picture = useState<SnCloudFile?>(null); | ||||||
|     final background = useState<SnCloudFile?>(null); |     final background = useState<SnCloudFile?>(null); | ||||||
|  |     final isPublic = useState(true); | ||||||
|  |     final isCommunity = useState(false); | ||||||
|  |  | ||||||
|     final chat = ref.watch(chatroomProvider(id)); |     final chat = ref.watch(chatroomProvider(id)); | ||||||
|  |  | ||||||
| @@ -480,12 +498,14 @@ class EditChatScreen extends HookConsumerWidget { | |||||||
|         descriptionController.text = chat.value!.description ?? ''; |         descriptionController.text = chat.value!.description ?? ''; | ||||||
|         picture.value = chat.value!.picture; |         picture.value = chat.value!.picture; | ||||||
|         background.value = chat.value!.background; |         background.value = chat.value!.background; | ||||||
|  |         isPublic.value = chat.value!.isPublic; | ||||||
|  |         isCommunity.value = chat.value!.isCommunity; | ||||||
|         currentRealm.value = joinedRealms.value?.firstWhereOrNull( |         currentRealm.value = joinedRealms.value?.firstWhereOrNull( | ||||||
|           (realm) => realm.id == chat.value!.realmId, |           (realm) => realm.id == chat.value!.realmId, | ||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|       return; |       return; | ||||||
|     }, [chat]); |     }, [chat, joinedRealms]); | ||||||
|  |  | ||||||
|     void setPicture(String position) async { |     void setPicture(String position) async { | ||||||
|       showLoadingModal(context); |       showLoadingModal(context); | ||||||
| @@ -503,9 +523,9 @@ class EditChatScreen extends HookConsumerWidget { | |||||||
|         image: result, |         image: result, | ||||||
|         allowedAspectRatios: [ |         allowedAspectRatios: [ | ||||||
|           if (position == 'background') |           if (position == 'background') | ||||||
|             CropAspectRatio(height: 7, width: 16) |             const CropAspectRatio(height: 7, width: 16) | ||||||
|           else |           else | ||||||
|             CropAspectRatio(height: 1, width: 1), |             const CropAspectRatio(height: 1, width: 1), | ||||||
|         ], |         ], | ||||||
|       ); |       ); | ||||||
|       if (result == null) { |       if (result == null) { | ||||||
| @@ -562,6 +582,8 @@ class EditChatScreen extends HookConsumerWidget { | |||||||
|             'background_id': background.value?.id, |             'background_id': background.value?.id, | ||||||
|             'picture_id': picture.value?.id, |             'picture_id': picture.value?.id, | ||||||
|             'realm_id': currentRealm.value?.id, |             'realm_id': currentRealm.value?.id, | ||||||
|  |             'is_public': isPublic.value, | ||||||
|  |             'is_community': isCommunity.value, | ||||||
|           }, |           }, | ||||||
|           options: Options(method: id == null ? 'POST' : 'PATCH'), |           options: Options(method: id == null ? 'POST' : 'PATCH'), | ||||||
|         ); |         ); | ||||||
| @@ -654,6 +676,19 @@ class EditChatScreen extends HookConsumerWidget { | |||||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), |                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|                 const SizedBox(height: 16), |                 const SizedBox(height: 16), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   title: const Text('isPublic').tr(), | ||||||
|  |                   subtitle: const Text('isPublicHint').tr(), | ||||||
|  |                   value: isPublic.value, | ||||||
|  |                   onChanged: (value) => isPublic.value = value ?? false, | ||||||
|  |                 ), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   title: const Text('isCommunity').tr(), | ||||||
|  |                   subtitle: const Text('isCommunityHint').tr(), | ||||||
|  |                   value: isCommunity.value, | ||||||
|  |                   onChanged: (value) => isCommunity.value = value ?? false, | ||||||
|  |                 ), | ||||||
|  |                 const SizedBox(height: 16), | ||||||
|                 Align( |                 Align( | ||||||
|                   alignment: Alignment.centerRight, |                   alignment: Alignment.centerRight, | ||||||
|                   child: TextButton.icon( |                   child: TextButton.icon( | ||||||
| @@ -754,7 +789,7 @@ class _ChatInvitesSheet extends HookConsumerWidget { | |||||||
|                               ), |                               ), | ||||||
|                               if (invite.chatRoom!.type == 1) |                               if (invite.chatRoom!.type == 1) | ||||||
|                                 Badge( |                                 Badge( | ||||||
|                                   label: Text('directMessage').tr(), |                                   label: const Text('directMessage').tr(), | ||||||
|                                   backgroundColor: |                                   backgroundColor: | ||||||
|                                       Theme.of(context).colorScheme.primary, |                                       Theme.of(context).colorScheme.primary, | ||||||
|                                   textColor: |                                   textColor: | ||||||
|   | |||||||
| @@ -25,7 +25,7 @@ final chatroomsJoinedProvider = | |||||||
| @Deprecated('Will be removed in 3.0. Use Ref instead') | @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||||
| // ignore: unused_element | // ignore: unused_element | ||||||
| typedef ChatroomsJoinedRef = AutoDisposeFutureProviderRef<List<SnChatRoom>>; | typedef ChatroomsJoinedRef = AutoDisposeFutureProviderRef<List<SnChatRoom>>; | ||||||
| String _$chatroomHash() => r'dce3c0fc407f178bb7c306a08b9fa545795a9205'; | String _$chatroomHash() => r'8dac7aaac50932e6dd213039102d43c1cf5f1d4e'; | ||||||
|  |  | ||||||
| /// Copied from Dart SDK | /// Copied from Dart SDK | ||||||
| class _SystemHash { | class _SystemHash { | ||||||
| @@ -164,7 +164,7 @@ class _ChatroomProviderElement | |||||||
|   String? get identifier => (origin as ChatroomProvider).identifier; |   String? get identifier => (origin as ChatroomProvider).identifier; | ||||||
| } | } | ||||||
|  |  | ||||||
| String _$chatroomIdentityHash() => r'4c349ea4265df7b0498cf26c82dbaabe3d868727'; | String _$chatroomIdentityHash() => r'ad6ad09b6fc4cf7c4abe146ea97f8e364a3d4fd0'; | ||||||
|  |  | ||||||
| /// See also [chatroomIdentity]. | /// See also [chatroomIdentity]. | ||||||
| @ProviderFor(chatroomIdentity) | @ProviderFor(chatroomIdentity) | ||||||
|   | |||||||
| @@ -295,6 +295,68 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final chatRoom = ref.watch(chatroomProvider(id)); |     final chatRoom = ref.watch(chatroomProvider(id)); | ||||||
|     final chatIdentity = ref.watch(chatroomIdentityProvider(id)); |     final chatIdentity = ref.watch(chatroomIdentityProvider(id)); | ||||||
|  |  | ||||||
|  |     if (chatIdentity.isLoading || chatRoom.isLoading) { | ||||||
|  |       return AppScaffold( | ||||||
|  |         appBar: AppBar(leading: const PageBackButton()), | ||||||
|  |         body: CircularProgressIndicator().center(), | ||||||
|  |       ); | ||||||
|  |     } else if (chatIdentity.value == null) { | ||||||
|  |       // Identity was not found, user was not joined | ||||||
|  |       return AppScaffold( | ||||||
|  |         appBar: AppBar(leading: const PageBackButton()), | ||||||
|  |         body: Center( | ||||||
|  |           child: | ||||||
|  |               ConstrainedBox( | ||||||
|  |                 constraints: const BoxConstraints(maxWidth: 280), | ||||||
|  |                 child: Column( | ||||||
|  |                   crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                   mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |                   children: [ | ||||||
|  |                     Icon( | ||||||
|  |                       chatRoom.value?.isCommunity == true | ||||||
|  |                           ? Symbols.person_add | ||||||
|  |                           : Symbols.person_remove, | ||||||
|  |                       size: 36, | ||||||
|  |                       fill: 1, | ||||||
|  |                     ).padding(bottom: 4), | ||||||
|  |                     Text('chatNotJoined').tr(), | ||||||
|  |                     if (chatRoom.value?.isCommunity != true) | ||||||
|  |                       Text( | ||||||
|  |                         'chatUnableJoin', | ||||||
|  |                         textAlign: TextAlign.center, | ||||||
|  |                       ).tr().bold() | ||||||
|  |                     else | ||||||
|  |                       FilledButton.tonalIcon( | ||||||
|  |                         onPressed: () async { | ||||||
|  |                           try { | ||||||
|  |                             showLoadingModal(context); | ||||||
|  |                             final apiClient = ref.read(apiClientProvider); | ||||||
|  |                             if (chatRoom.value == null) { | ||||||
|  |                               hideLoadingModal(context); | ||||||
|  |                               return; | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             await apiClient.post( | ||||||
|  |                               '/chat/${chatRoom.value!.id}/members/me', | ||||||
|  |                             ); | ||||||
|  |                             ref.invalidate(chatroomIdentityProvider(id)); | ||||||
|  |                           } catch (err) { | ||||||
|  |                             showErrorAlert(err); | ||||||
|  |                           } finally { | ||||||
|  |                             if (context.mounted) hideLoadingModal(context); | ||||||
|  |                           } | ||||||
|  |                         }, | ||||||
|  |                         label: Text('chatJoin').tr(), | ||||||
|  |                         icon: const Icon(Icons.add), | ||||||
|  |                       ).padding(top: 8), | ||||||
|  |                   ], | ||||||
|  |                 ), | ||||||
|  |               ).center(), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     final messages = ref.watch(messagesNotifierProvider(id)); |     final messages = ref.watch(messagesNotifierProvider(id)); | ||||||
|     final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); |     final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); | ||||||
|     final ws = ref.watch(websocketProvider); |     final ws = ref.watch(websocketProvider); | ||||||
| @@ -429,6 +491,28 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|       return () => subscription.cancel(); |       return () => subscription.cancel(); | ||||||
|     }, [ws, chatRoom]); |     }, [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 { |     Future<void> pickPhotoMedia() async { | ||||||
|       final result = await ref |       final result = await ref | ||||||
|           .watch(imagePickerProvider) |           .watch(imagePickerProvider) | ||||||
| @@ -603,7 +687,7 @@ class ChatRoomScreen extends HookConsumerWidget { | |||||||
|           IconButton( |           IconButton( | ||||||
|             icon: const Icon(Icons.more_vert), |             icon: const Icon(Icons.more_vert), | ||||||
|             onPressed: () { |             onPressed: () { | ||||||
|               context.push('/chat/id/detail'); |               context.push('/chat/$id/detail'); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
|           const Gap(8), |           const Gap(8), | ||||||
|   | |||||||
| @@ -584,8 +584,8 @@ class _ChatMemberListSheet extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     Future<void> invitePerson() async { |     Future<void> invitePerson() async { | ||||||
|       final result = await showModalBottomSheet( |       final result = await showModalBottomSheet( | ||||||
|         isScrollControlled: true, |  | ||||||
|         context: context, |         context: context, | ||||||
|  |         useRootNavigator: true, | ||||||
|         builder: (context) => const AccountPickerSheet(), |         builder: (context) => const AccountPickerSheet(), | ||||||
|       ); |       ); | ||||||
|       if (result == null) return; |       if (result == null) return; | ||||||
|   | |||||||
							
								
								
									
										64
									
								
								lib/screens/discovery/realms.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								lib/screens/discovery/realms.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | |||||||
|  | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/widgets/app_scaffold.dart'; | ||||||
|  | import 'package:island/widgets/realm/realm_list.dart'; | ||||||
|  | import 'dart:async'; | ||||||
|  |  | ||||||
|  | class DiscoveryRealmsScreen extends HookConsumerWidget { | ||||||
|  |   const DiscoveryRealmsScreen({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     Timer? debounceTimer; | ||||||
|  |     final searchController = useTextEditingController(); | ||||||
|  |     final currentQuery = useState<String?>(null); | ||||||
|  |  | ||||||
|  |     return AppScaffold( | ||||||
|  |       appBar: AppBar(title: Text('discoverRealms'.tr())), | ||||||
|  |       body: Stack( | ||||||
|  |         children: [ | ||||||
|  |           CustomScrollView( | ||||||
|  |             slivers: [ | ||||||
|  |               SliverGap(80), | ||||||
|  |               SliverRealmList( | ||||||
|  |                 query: currentQuery.value, | ||||||
|  |                 key: ValueKey(currentQuery.value), | ||||||
|  |               ), | ||||||
|  |               SliverGap(MediaQuery.of(context).padding.bottom + 16), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |           Positioned( | ||||||
|  |             top: 0, | ||||||
|  |             left: 0, | ||||||
|  |             right: 0, | ||||||
|  |             child: Padding( | ||||||
|  |               padding: const EdgeInsets.all(16), | ||||||
|  |               child: SearchBar( | ||||||
|  |                 elevation: WidgetStateProperty.all(4), | ||||||
|  |                 controller: searchController, | ||||||
|  |                 hintText: 'search'.tr(), | ||||||
|  |                 leading: const Icon(Icons.search), | ||||||
|  |                 padding: WidgetStateProperty.all( | ||||||
|  |                   const EdgeInsets.symmetric(horizontal: 24), | ||||||
|  |                 ), | ||||||
|  |                 onChanged: (value) { | ||||||
|  |                   if (debounceTimer?.isActive ?? false) { | ||||||
|  |                     debounceTimer?.cancel(); | ||||||
|  |                   } | ||||||
|  |                   debounceTimer = Timer(const Duration(milliseconds: 300), () { | ||||||
|  |                     if (currentQuery.value != value) { | ||||||
|  |                       currentQuery.value = value; | ||||||
|  |                     } | ||||||
|  |                   }); | ||||||
|  |                 }, | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,24 +1,26 @@ | |||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/activity.dart'; | import 'package:island/models/activity.dart'; | ||||||
|  | import 'package:island/models/realm.dart'; | ||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
| import 'package:island/services/responsive.dart'; | import 'package:island/services/responsive.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/models/post.dart'; | import 'package:island/models/post.dart'; | ||||||
| import 'package:island/widgets/check_in.dart'; | import 'package:island/widgets/check_in.dart'; | ||||||
| import 'package:island/widgets/post/post_item.dart'; | import 'package:island/widgets/post/post_item.dart'; | ||||||
| import 'package:island/widgets/tour/tour.dart'; |  | ||||||
| import 'package:island/screens/tabs.dart'; | import 'package:island/screens/tabs.dart'; | ||||||
| import 'package:material_symbols_icons/symbols.dart'; | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
| import 'package:island/pods/network.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'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
| import 'package:island/models/realm.dart'; |  | ||||||
|  |  | ||||||
| part 'explore.g.dart'; | part 'explore.g.dart'; | ||||||
|  |  | ||||||
| @@ -84,65 +86,64 @@ class ExploreScreen extends HookConsumerWidget { | |||||||
|       activityListNotifierProvider(currentFilter.value).notifier, |       activityListNotifierProvider(currentFilter.value).notifier, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|     return TourTriggerWidget( |     return AppScaffold( | ||||||
|       child: AppScaffold( |       extendBody: false, // Prevent conflicts with tabs navigation | ||||||
|         extendBody: false, // Prevent conflicts with tabs navigation |       appBar: AppBar( | ||||||
|         appBar: AppBar( |         toolbarHeight: 0, | ||||||
|           toolbarHeight: 0, |         bottom: TabBar( | ||||||
|           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( |  | ||||||
|           controller: tabController, |           controller: tabController, | ||||||
|           children: [ |           tabs: [ | ||||||
|             _buildActivityList(ref, null), |             Tab( | ||||||
|             _buildActivityList(ref, 'subscriptions'), |               child: Text( | ||||||
|             _buildActivityList(ref, 'friends'), |                 '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'), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -179,10 +180,8 @@ class _DiscoveryActivityItem extends StatelessWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     final items = |     final items = data['items'] as List; | ||||||
|         (data['items'] as List) |     final type = items.firstOrNull?['type'] ?? 'unknown'; | ||||||
|             .map((e) => SnRealm.fromJson(e['data'] as Map<String, dynamic>)) |  | ||||||
|             .toList(); |  | ||||||
|  |  | ||||||
|     return Column( |     return Column( | ||||||
|       crossAxisAlignment: CrossAxisAlignment.start, |       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
| @@ -193,7 +192,11 @@ class _DiscoveryActivityItem extends StatelessWidget { | |||||||
|             const Icon(Symbols.explore, size: 19), |             const Icon(Symbols.explore, size: 19), | ||||||
|             const Gap(8), |             const Gap(8), | ||||||
|             Text( |             Text( | ||||||
|               'discoverCommunities'.tr(), |               (switch (type) { | ||||||
|  |                 'realm' => 'discoverRealms', | ||||||
|  |                 'publisher' => 'discoverPublishers', | ||||||
|  |                 _ => 'unknown', | ||||||
|  |               }).tr(), | ||||||
|               style: Theme.of(context).textTheme.titleMedium, |               style: Theme.of(context).textTheme.titleMedium, | ||||||
|             ).padding(top: 1), |             ).padding(top: 1), | ||||||
|           ], |           ], | ||||||
| @@ -203,98 +206,31 @@ class _DiscoveryActivityItem extends StatelessWidget { | |||||||
|           child: ListView.builder( |           child: ListView.builder( | ||||||
|             scrollDirection: Axis.horizontal, |             scrollDirection: Axis.horizontal, | ||||||
|             itemCount: items.length, |             itemCount: items.length, | ||||||
|             padding: const EdgeInsets.only(right: 8), |             padding: const EdgeInsets.symmetric(horizontal: 8), | ||||||
|             itemBuilder: (context, index) { |             itemBuilder: (context, index) { | ||||||
|               final realm = items[index]; |               final item = items[index]; | ||||||
|               return _RealmCard(realm: realm); |               switch (type) { | ||||||
|  |                 case 'realm': | ||||||
|  |                   return RealmCard( | ||||||
|  |                     realm: SnRealm.fromJson(item['data']), | ||||||
|  |                     maxWidth: 280, | ||||||
|  |                   ); | ||||||
|  |                 case 'publisher': | ||||||
|  |                   return PublisherCard( | ||||||
|  |                     publisher: SnPublisher.fromJson(item['data']), | ||||||
|  |                     maxWidth: 280, | ||||||
|  |                   ); | ||||||
|  |                 default: | ||||||
|  |                   return Placeholder(); | ||||||
|  |               } | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
|         ), |         ).padding(bottom: 4), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class _RealmCard extends ConsumerWidget { |  | ||||||
|   final SnRealm realm; |  | ||||||
|  |  | ||||||
|   const _RealmCard({required this.realm}); |  | ||||||
|  |  | ||||||
|   @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, |  | ||||||
|       ); |  | ||||||
|     } else { |  | ||||||
|       imageWidget = Container( |  | ||||||
|         color: Theme.of(context).colorScheme.secondaryContainer, |  | ||||||
|         child: Center( |  | ||||||
|           child: Icon( |  | ||||||
|             Symbols.photo_camera, |  | ||||||
|             color: Theme.of(context).colorScheme.onSecondaryContainer, |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return Card( |  | ||||||
|       clipBehavior: Clip.antiAlias, |  | ||||||
|       margin: const EdgeInsets.only(left: 16, bottom: 8, top: 8), |  | ||||||
|       child: InkWell( |  | ||||||
|         onTap: () { |  | ||||||
|           context.push('/realms/${realm.slug}'); |  | ||||||
|         }, |  | ||||||
|         child: ConstrainedBox( |  | ||||||
|           constraints: const BoxConstraints(maxWidth: 280), |  | ||||||
|           child: AspectRatio( |  | ||||||
|             aspectRatio: 16 / 7, |  | ||||||
|             child: Stack( |  | ||||||
|               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: Text( |  | ||||||
|                       realm.name, |  | ||||||
|                       style: Theme.of(context).textTheme.titleSmall?.copyWith( |  | ||||||
|                         color: Colors.white, |  | ||||||
|                         fontWeight: FontWeight.bold, |  | ||||||
|                       ), |  | ||||||
|                       maxLines: 2, |  | ||||||
|                       overflow: TextOverflow.ellipsis, |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|               ], |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| class _ActivityListView extends HookConsumerWidget { | class _ActivityListView extends HookConsumerWidget { | ||||||
|   final CursorPagingData<SnActivity> data; |   final CursorPagingData<SnActivity> data; | ||||||
|   final int widgetCount; |   final int widgetCount; | ||||||
| @@ -405,6 +341,7 @@ class ActivityListNotifier extends _$ActivityListNotifier | |||||||
|       if (cursor != null) 'cursor': cursor, |       if (cursor != null) 'cursor': cursor, | ||||||
|       'take': take, |       'take': take, | ||||||
|       if (filter != null) 'filter': filter, |       if (filter != null) 'filter': filter, | ||||||
|  |       if (kDebugMode) 'debugInclude': 'realms,publishers', | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     final response = await client.get( |     final response = await client.get( | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ part of 'explore.dart'; | |||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
|  |  | ||||||
| String _$activityListNotifierHash() => | String _$activityListNotifierHash() => | ||||||
|     r'14ec2f211c86e1e64a9a34b142d0e8f78ff6361a'; |     r'57e9dcec944a9f88f8508b69fc91342592f5b349'; | ||||||
|  |  | ||||||
| /// Copied from Dart SDK | /// Copied from Dart SDK | ||||||
| class _SystemHash { | class _SystemHash { | ||||||
|   | |||||||
| @@ -4,19 +4,18 @@ import 'dart:math' as math; | |||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:flutter/services.dart'; |  | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/user.dart'; | import 'package:island/models/user.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/pods/websocket.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/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/content/markdown.dart'; | import 'package:island/widgets/content/markdown.dart'; | ||||||
| import 'package:relative_time/relative_time.dart'; | import 'package:relative_time/relative_time.dart'; | ||||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
| import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
| import 'package:styled_widget/styled_widget.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'; | part 'notification.g.dart'; | ||||||
|  |  | ||||||
| @@ -180,36 +179,17 @@ class NotificationScreen extends HookConsumerWidget { | |||||||
|                             ), |                             ), | ||||||
|                           ), |                           ), | ||||||
|                   onTap: () { |                   onTap: () { | ||||||
|                     if (notification.meta['link'] is String) { |                     if (notification.meta['action_uri'] != null) { | ||||||
|                       final href = notification.meta['link']; |                       var uri = notification.meta['action_uri'] as String; | ||||||
|                       final uri = Uri.tryParse(href); |                       if (uri.startsWith('/')) { | ||||||
|                       if (uri == null) { |                         // In-app routes | ||||||
|                         showSnackBar( |                         rootNavigatorKey.currentContext?.push( | ||||||
|                           'brokenLink'.tr(args: []), |                           notification.meta['action_uri'], | ||||||
|                           action: SnackBarAction( |  | ||||||
|                             label: 'copyToClipboard'.tr(), |  | ||||||
|                             onPressed: () { |  | ||||||
|                               Clipboard.setData(ClipboardData(text: href)); |  | ||||||
|                               clearSnackBar(context); |  | ||||||
|                             }, |  | ||||||
|                           ), |  | ||||||
|                         ); |                         ); | ||||||
|                         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 | @riverpod | ||||||
| Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async { | Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async { | ||||||
|   final publisher = await ref.watch(publisherProvider(pubName).future); |   try { | ||||||
|   if (publisher.background == null) return null; |     final publisher = await ref.watch(publisherProvider(pubName).future); | ||||||
|   final palette = await PaletteGenerator.fromImageProvider( |     if (publisher.background == null) return null; | ||||||
|     CloudImageWidget.provider( |     final palette = await PaletteGenerator.fromImageProvider( | ||||||
|       fileId: publisher.background!.id, |       CloudImageWidget.provider( | ||||||
|       serverUrl: ref.watch(serverUrlProvider), |         fileId: publisher.background!.id, | ||||||
|     ), |         serverUrl: ref.watch(serverUrlProvider), | ||||||
|   ); |       ), | ||||||
|   final dominantColor = palette.dominantColor?.color; |     ); | ||||||
|   if (dominantColor == null) return null; |     final dominantColor = palette.dominantColor?.color; | ||||||
|   return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; |     if (dominantColor == null) return null; | ||||||
|  |     return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||||
|  |   } catch (_) { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class PublisherProfileScreen extends HookConsumerWidget { | class PublisherProfileScreen extends HookConsumerWidget { | ||||||
|   final String name; |   final String name; | ||||||
|   const PublisherProfileScreen({ |   const PublisherProfileScreen({super.key, required this.name}); | ||||||
|     super.key, |  | ||||||
|     required this.name, |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|   | |||||||
| @@ -1,12 +1,17 @@ | |||||||
| import 'package:dio/dio.dart'; | import 'package:dio/dio.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:island/screens/chat/chat.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:island/models/chat.dart'; | ||||||
|  | import 'package:island/services/color.dart'; | ||||||
|  | import 'package:palette_generator/palette_generator.dart'; | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:go_router/go_router.dart'; | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/realm.dart'; | import 'package:island/models/realm.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
|  | import 'package:island/pods/config.dart'; | ||||||
| import 'package:island/screens/realm/realms.dart'; | import 'package:island/screens/realm/realms.dart'; | ||||||
| import 'package:island/widgets/account/account_picker.dart'; | import 'package:island/widgets/account/account_picker.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; | import 'package:island/widgets/alert.dart'; | ||||||
| @@ -19,11 +24,40 @@ import 'package:styled_widget/styled_widget.dart'; | |||||||
|  |  | ||||||
| part 'detail.g.dart'; | part 'detail.g.dart'; | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | Future<Color?> realmAppbarForegroundColor(Ref ref, String realmSlug) async { | ||||||
|  |   final realm = await ref.watch(realmProvider(realmSlug).future); | ||||||
|  |   if (realm?.background == null) return null; | ||||||
|  |   final palette = await PaletteGenerator.fromImageProvider( | ||||||
|  |     CloudImageWidget.provider( | ||||||
|  |       fileId: realm!.background!.id, | ||||||
|  |       serverUrl: ref.watch(serverUrlProvider), | ||||||
|  |     ), | ||||||
|  |   ); | ||||||
|  |   final dominantColor = palette.dominantColor?.color; | ||||||
|  |   if (dominantColor == null) return null; | ||||||
|  |   return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; | ||||||
|  | } | ||||||
|  |  | ||||||
| @riverpod | @riverpod | ||||||
| Future<SnRealmMember?> realmIdentity(Ref ref, String realmSlug) async { | Future<SnRealmMember?> realmIdentity(Ref ref, String realmSlug) async { | ||||||
|  |   try { | ||||||
|  |     final apiClient = ref.watch(apiClientProvider); | ||||||
|  |     final response = await apiClient.get('/realms/$realmSlug/members/me'); | ||||||
|  |     return SnRealmMember.fromJson(response.data); | ||||||
|  |   } catch (err) { | ||||||
|  |     if (err is DioException && err.response?.statusCode == 404) { | ||||||
|  |       return null; // No identity found, user is not a member | ||||||
|  |     } | ||||||
|  |     rethrow; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | Future<List<SnChatRoom>> realmChatRooms(Ref ref, String realmSlug) async { | ||||||
|   final apiClient = ref.watch(apiClientProvider); |   final apiClient = ref.watch(apiClientProvider); | ||||||
|   final response = await apiClient.get('/realms/$realmSlug/members/me'); |   final response = await apiClient.get('/realms/$realmSlug/chat'); | ||||||
|   return SnRealmMember.fromJson(response.data); |   return (response.data as List).map((e) => SnChatRoom.fromJson(e)).toList(); | ||||||
| } | } | ||||||
|  |  | ||||||
| class RealmDetailScreen extends HookConsumerWidget { | class RealmDetailScreen extends HookConsumerWidget { | ||||||
| @@ -34,9 +68,10 @@ class RealmDetailScreen extends HookConsumerWidget { | |||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final realmState = ref.watch(realmProvider(slug)); |     final realmState = ref.watch(realmProvider(slug)); | ||||||
|  |     final appbarColor = ref.watch(realmAppbarForegroundColorProvider(slug)); | ||||||
|  |  | ||||||
|     const iconShadow = Shadow( |     final iconShadow = Shadow( | ||||||
|       color: Colors.black54, |       color: appbarColor.value?.invert ?? Colors.black54, | ||||||
|       blurRadius: 5.0, |       blurRadius: 5.0, | ||||||
|       offset: Offset(1.0, 1.0), |       offset: Offset(1.0, 1.0), | ||||||
|     ); |     ); | ||||||
| @@ -51,7 +86,11 @@ class RealmDetailScreen extends HookConsumerWidget { | |||||||
|                 SliverAppBar( |                 SliverAppBar( | ||||||
|                   expandedHeight: 180, |                   expandedHeight: 180, | ||||||
|                   pinned: true, |                   pinned: true, | ||||||
|                   leading: PageBackButton(shadows: [iconShadow]), |                   foregroundColor: appbarColor.value, | ||||||
|  |                   leading: PageBackButton( | ||||||
|  |                     color: appbarColor.value, | ||||||
|  |                     shadows: [iconShadow], | ||||||
|  |                   ), | ||||||
|                   flexibleSpace: FlexibleSpaceBar( |                   flexibleSpace: FlexibleSpaceBar( | ||||||
|                     background: |                     background: | ||||||
|                         realm!.background?.id != null |                         realm!.background?.id != null | ||||||
| @@ -63,14 +102,16 @@ class RealmDetailScreen extends HookConsumerWidget { | |||||||
|                     title: Text( |                     title: Text( | ||||||
|                       realm.name, |                       realm.name, | ||||||
|                       style: TextStyle( |                       style: TextStyle( | ||||||
|                         color: Theme.of(context).appBarTheme.foregroundColor, |                         color: | ||||||
|  |                             appbarColor.value ?? | ||||||
|  |                             Theme.of(context).appBarTheme.foregroundColor, | ||||||
|                         shadows: [iconShadow], |                         shadows: [iconShadow], | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|                   ), |                   ), | ||||||
|                   actions: [ |                   actions: [ | ||||||
|                     IconButton( |                     IconButton( | ||||||
|                       icon: const Icon(Icons.people, shadows: [iconShadow]), |                       icon: Icon(Icons.people, shadows: [iconShadow]), | ||||||
|                       onPressed: () { |                       onPressed: () { | ||||||
|                         showModalBottomSheet( |                         showModalBottomSheet( | ||||||
|                           isScrollControlled: true, |                           isScrollControlled: true, | ||||||
| @@ -86,18 +127,97 @@ class RealmDetailScreen extends HookConsumerWidget { | |||||||
|                   ], |                   ], | ||||||
|                 ), |                 ), | ||||||
|                 SliverToBoxAdapter( |                 SliverToBoxAdapter( | ||||||
|                   child: Padding( |                   child: ref | ||||||
|                     padding: const EdgeInsets.all(16.0), |                       .watch(realmIdentityProvider(slug)) | ||||||
|                     child: Column( |                       .when( | ||||||
|                       crossAxisAlignment: CrossAxisAlignment.start, |                         loading: () => const SizedBox.shrink(), | ||||||
|                       children: [ |                         error: (_, _) => const SizedBox.shrink(), | ||||||
|                         Text( |                         data: | ||||||
|                           realm.description, |                             (identity) => Column( | ||||||
|                           style: const TextStyle(fontSize: 16), |                               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|                         ), |                               children: [ | ||||||
|                       ], |                                 ExpansionTile( | ||||||
|                     ), |                                   title: const Text('description').tr(), | ||||||
|                   ), |                                   initiallyExpanded: identity == null, | ||||||
|  |                                   tilePadding: EdgeInsets.symmetric( | ||||||
|  |                                     horizontal: 20, | ||||||
|  |                                   ), | ||||||
|  |                                   expandedCrossAxisAlignment: | ||||||
|  |                                       CrossAxisAlignment.stretch, | ||||||
|  |                                   children: [ | ||||||
|  |                                     Text( | ||||||
|  |                                       realm.description, | ||||||
|  |                                       style: const TextStyle(fontSize: 16), | ||||||
|  |                                     ).padding( | ||||||
|  |                                       horizontal: 20, | ||||||
|  |                                       bottom: 16, | ||||||
|  |                                       top: 8, | ||||||
|  |                                     ), | ||||||
|  |                                   ], | ||||||
|  |                                 ), | ||||||
|  |                                 if (identity == null && realm.isCommunity) | ||||||
|  |                                   FilledButton.tonalIcon( | ||||||
|  |                                     onPressed: () async { | ||||||
|  |                                       try { | ||||||
|  |                                         final apiClient = ref.read( | ||||||
|  |                                           apiClientProvider, | ||||||
|  |                                         ); | ||||||
|  |                                         await apiClient.post( | ||||||
|  |                                           '/realms/$slug/members/me', | ||||||
|  |                                         ); | ||||||
|  |                                         ref.invalidate( | ||||||
|  |                                           realmIdentityProvider(slug), | ||||||
|  |                                         ); | ||||||
|  |                                         ref.invalidate(realmsJoinedProvider); | ||||||
|  |                                         showSnackBar('realmJoinSuccess'.tr()); | ||||||
|  |                                       } catch (err) { | ||||||
|  |                                         showErrorAlert(err); | ||||||
|  |                                       } | ||||||
|  |                                     }, | ||||||
|  |                                     icon: const Icon(Symbols.add), | ||||||
|  |                                     label: const Text('realmJoin').tr(), | ||||||
|  |                                   ).padding(horizontal: 16, vertical: 16) | ||||||
|  |                                 else | ||||||
|  |                                   const SizedBox.shrink(), | ||||||
|  |                               ], | ||||||
|  |                             ), | ||||||
|  |                       ), | ||||||
|  |                 ), | ||||||
|  |                 const SliverToBoxAdapter(child: Divider(height: 1)), | ||||||
|  |                 Consumer( | ||||||
|  |                   builder: (context, ref, _) { | ||||||
|  |                     final chatRooms = ref.watch(realmChatRoomsProvider(slug)); | ||||||
|  |                     return chatRooms.when( | ||||||
|  |                       loading: | ||||||
|  |                           () => const SliverToBoxAdapter( | ||||||
|  |                             child: Center(child: CircularProgressIndicator()), | ||||||
|  |                           ), | ||||||
|  |                       error: | ||||||
|  |                           (error, _) => SliverToBoxAdapter( | ||||||
|  |                             child: Center(child: Text('Error: $error')), | ||||||
|  |                           ), | ||||||
|  |                       data: (rooms) { | ||||||
|  |                         if (rooms.isEmpty) { | ||||||
|  |                           return const SliverToBoxAdapter( | ||||||
|  |                             child: SizedBox.shrink(), | ||||||
|  |                           ); | ||||||
|  |                         } | ||||||
|  |                         return SliverList( | ||||||
|  |                           delegate: SliverChildBuilderDelegate(( | ||||||
|  |                             context, | ||||||
|  |                             index, | ||||||
|  |                           ) { | ||||||
|  |                             return ChatRoomListTile( | ||||||
|  |                               room: rooms[index], | ||||||
|  |                               onTap: () { | ||||||
|  |                                 context.push('/chat/${rooms[index].id}'); | ||||||
|  |                               }, | ||||||
|  |                             ); | ||||||
|  |                           }, childCount: rooms.length), | ||||||
|  |                         ); | ||||||
|  |                       }, | ||||||
|  |                     ); | ||||||
|  |                   }, | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|             ), |             ), | ||||||
| @@ -114,8 +234,8 @@ class _RealmActionMenu extends HookConsumerWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|     final realmIdentityAsync = ref.watch(realmIdentityProvider(realmSlug)); |     final realmIdentity = ref.watch(realmIdentityProvider(realmSlug)); | ||||||
|     final isModerator = realmIdentityAsync.when( |     final isModerator = realmIdentity.when( | ||||||
|       data: (identity) => (identity?.role ?? 0) >= 50, |       data: (identity) => (identity?.role ?? 0) >= 50, | ||||||
|       loading: () => false, |       loading: () => false, | ||||||
|       error: (_, _) => false, |       error: (_, _) => false, | ||||||
| @@ -141,7 +261,7 @@ class _RealmActionMenu extends HookConsumerWidget { | |||||||
|                   ], |                   ], | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|             realmIdentityAsync.when( |             realmIdentity.when( | ||||||
|               data: |               data: | ||||||
|                   (identity) => |                   (identity) => | ||||||
|                       (identity?.role ?? 0) >= 100 |                       (identity?.role ?? 0) >= 100 | ||||||
|   | |||||||
| @@ -6,7 +6,8 @@ part of 'detail.dart'; | |||||||
| // RiverpodGenerator | // RiverpodGenerator | ||||||
| // ************************************************************************** | // ************************************************************************** | ||||||
|  |  | ||||||
| String _$realmIdentityHash() => r'eac6e829b5b46bcfadbf201ab6f918d78c894b9f'; | String _$realmAppbarForegroundColorHash() => | ||||||
|  |     r'14b5563d861996ea182d0d2db7aa5c2bb3bbaf48'; | ||||||
|  |  | ||||||
| /// Copied from Dart SDK | /// Copied from Dart SDK | ||||||
| class _SystemHash { | class _SystemHash { | ||||||
| @@ -29,6 +30,133 @@ class _SystemHash { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /// See also [realmAppbarForegroundColor]. | ||||||
|  | @ProviderFor(realmAppbarForegroundColor) | ||||||
|  | const realmAppbarForegroundColorProvider = RealmAppbarForegroundColorFamily(); | ||||||
|  |  | ||||||
|  | /// See also [realmAppbarForegroundColor]. | ||||||
|  | class RealmAppbarForegroundColorFamily extends Family<AsyncValue<Color?>> { | ||||||
|  |   /// See also [realmAppbarForegroundColor]. | ||||||
|  |   const RealmAppbarForegroundColorFamily(); | ||||||
|  |  | ||||||
|  |   /// See also [realmAppbarForegroundColor]. | ||||||
|  |   RealmAppbarForegroundColorProvider call(String realmSlug) { | ||||||
|  |     return RealmAppbarForegroundColorProvider(realmSlug); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   RealmAppbarForegroundColorProvider getProviderOverride( | ||||||
|  |     covariant RealmAppbarForegroundColorProvider provider, | ||||||
|  |   ) { | ||||||
|  |     return call(provider.realmSlug); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||||
|  |       _allTransitiveDependencies; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? get name => r'realmAppbarForegroundColorProvider'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [realmAppbarForegroundColor]. | ||||||
|  | class RealmAppbarForegroundColorProvider | ||||||
|  |     extends AutoDisposeFutureProvider<Color?> { | ||||||
|  |   /// See also [realmAppbarForegroundColor]. | ||||||
|  |   RealmAppbarForegroundColorProvider(String realmSlug) | ||||||
|  |     : this._internal( | ||||||
|  |         (ref) => realmAppbarForegroundColor( | ||||||
|  |           ref as RealmAppbarForegroundColorRef, | ||||||
|  |           realmSlug, | ||||||
|  |         ), | ||||||
|  |         from: realmAppbarForegroundColorProvider, | ||||||
|  |         name: r'realmAppbarForegroundColorProvider', | ||||||
|  |         debugGetCreateSourceHash: | ||||||
|  |             const bool.fromEnvironment('dart.vm.product') | ||||||
|  |                 ? null | ||||||
|  |                 : _$realmAppbarForegroundColorHash, | ||||||
|  |         dependencies: RealmAppbarForegroundColorFamily._dependencies, | ||||||
|  |         allTransitiveDependencies: | ||||||
|  |             RealmAppbarForegroundColorFamily._allTransitiveDependencies, | ||||||
|  |         realmSlug: realmSlug, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |   RealmAppbarForegroundColorProvider._internal( | ||||||
|  |     super._createNotifier, { | ||||||
|  |     required super.name, | ||||||
|  |     required super.dependencies, | ||||||
|  |     required super.allTransitiveDependencies, | ||||||
|  |     required super.debugGetCreateSourceHash, | ||||||
|  |     required super.from, | ||||||
|  |     required this.realmSlug, | ||||||
|  |   }) : super.internal(); | ||||||
|  |  | ||||||
|  |   final String realmSlug; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Override overrideWith( | ||||||
|  |     FutureOr<Color?> Function(RealmAppbarForegroundColorRef provider) create, | ||||||
|  |   ) { | ||||||
|  |     return ProviderOverride( | ||||||
|  |       origin: this, | ||||||
|  |       override: RealmAppbarForegroundColorProvider._internal( | ||||||
|  |         (ref) => create(ref as RealmAppbarForegroundColorRef), | ||||||
|  |         from: from, | ||||||
|  |         name: null, | ||||||
|  |         dependencies: null, | ||||||
|  |         allTransitiveDependencies: null, | ||||||
|  |         debugGetCreateSourceHash: null, | ||||||
|  |         realmSlug: realmSlug, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AutoDisposeFutureProviderElement<Color?> createElement() { | ||||||
|  |     return _RealmAppbarForegroundColorProviderElement(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) { | ||||||
|  |     return other is RealmAppbarForegroundColorProvider && | ||||||
|  |         other.realmSlug == realmSlug; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   int get hashCode { | ||||||
|  |     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||||
|  |     hash = _SystemHash.combine(hash, realmSlug.hashCode); | ||||||
|  |  | ||||||
|  |     return _SystemHash.finish(hash); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||||
|  | // ignore: unused_element | ||||||
|  | mixin RealmAppbarForegroundColorRef on AutoDisposeFutureProviderRef<Color?> { | ||||||
|  |   /// The parameter `realmSlug` of this provider. | ||||||
|  |   String get realmSlug; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _RealmAppbarForegroundColorProviderElement | ||||||
|  |     extends AutoDisposeFutureProviderElement<Color?> | ||||||
|  |     with RealmAppbarForegroundColorRef { | ||||||
|  |   _RealmAppbarForegroundColorProviderElement(super.provider); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String get realmSlug => | ||||||
|  |       (origin as RealmAppbarForegroundColorProvider).realmSlug; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | String _$realmIdentityHash() => r'308d43eef8a6145c762d27bdf7e12e27149524db'; | ||||||
|  |  | ||||||
| /// See also [realmIdentity]. | /// See also [realmIdentity]. | ||||||
| @ProviderFor(realmIdentity) | @ProviderFor(realmIdentity) | ||||||
| const realmIdentityProvider = RealmIdentityFamily(); | const realmIdentityProvider = RealmIdentityFamily(); | ||||||
| @@ -148,6 +276,128 @@ class _RealmIdentityProviderElement | |||||||
|   String get realmSlug => (origin as RealmIdentityProvider).realmSlug; |   String get realmSlug => (origin as RealmIdentityProvider).realmSlug; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | String _$realmChatRoomsHash() => r'8207c1e6f0922323967f208efeed027e943039cc'; | ||||||
|  |  | ||||||
|  | /// See also [realmChatRooms]. | ||||||
|  | @ProviderFor(realmChatRooms) | ||||||
|  | const realmChatRoomsProvider = RealmChatRoomsFamily(); | ||||||
|  |  | ||||||
|  | /// See also [realmChatRooms]. | ||||||
|  | class RealmChatRoomsFamily extends Family<AsyncValue<List<SnChatRoom>>> { | ||||||
|  |   /// See also [realmChatRooms]. | ||||||
|  |   const RealmChatRoomsFamily(); | ||||||
|  |  | ||||||
|  |   /// See also [realmChatRooms]. | ||||||
|  |   RealmChatRoomsProvider call(String realmSlug) { | ||||||
|  |     return RealmChatRoomsProvider(realmSlug); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   RealmChatRoomsProvider getProviderOverride( | ||||||
|  |     covariant RealmChatRoomsProvider provider, | ||||||
|  |   ) { | ||||||
|  |     return call(provider.realmSlug); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||||
|  |       _allTransitiveDependencies; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? get name => r'realmChatRoomsProvider'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [realmChatRooms]. | ||||||
|  | class RealmChatRoomsProvider | ||||||
|  |     extends AutoDisposeFutureProvider<List<SnChatRoom>> { | ||||||
|  |   /// See also [realmChatRooms]. | ||||||
|  |   RealmChatRoomsProvider(String realmSlug) | ||||||
|  |     : this._internal( | ||||||
|  |         (ref) => realmChatRooms(ref as RealmChatRoomsRef, realmSlug), | ||||||
|  |         from: realmChatRoomsProvider, | ||||||
|  |         name: r'realmChatRoomsProvider', | ||||||
|  |         debugGetCreateSourceHash: | ||||||
|  |             const bool.fromEnvironment('dart.vm.product') | ||||||
|  |                 ? null | ||||||
|  |                 : _$realmChatRoomsHash, | ||||||
|  |         dependencies: RealmChatRoomsFamily._dependencies, | ||||||
|  |         allTransitiveDependencies: | ||||||
|  |             RealmChatRoomsFamily._allTransitiveDependencies, | ||||||
|  |         realmSlug: realmSlug, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |   RealmChatRoomsProvider._internal( | ||||||
|  |     super._createNotifier, { | ||||||
|  |     required super.name, | ||||||
|  |     required super.dependencies, | ||||||
|  |     required super.allTransitiveDependencies, | ||||||
|  |     required super.debugGetCreateSourceHash, | ||||||
|  |     required super.from, | ||||||
|  |     required this.realmSlug, | ||||||
|  |   }) : super.internal(); | ||||||
|  |  | ||||||
|  |   final String realmSlug; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Override overrideWith( | ||||||
|  |     FutureOr<List<SnChatRoom>> Function(RealmChatRoomsRef provider) create, | ||||||
|  |   ) { | ||||||
|  |     return ProviderOverride( | ||||||
|  |       origin: this, | ||||||
|  |       override: RealmChatRoomsProvider._internal( | ||||||
|  |         (ref) => create(ref as RealmChatRoomsRef), | ||||||
|  |         from: from, | ||||||
|  |         name: null, | ||||||
|  |         dependencies: null, | ||||||
|  |         allTransitiveDependencies: null, | ||||||
|  |         debugGetCreateSourceHash: null, | ||||||
|  |         realmSlug: realmSlug, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AutoDisposeFutureProviderElement<List<SnChatRoom>> createElement() { | ||||||
|  |     return _RealmChatRoomsProviderElement(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) { | ||||||
|  |     return other is RealmChatRoomsProvider && other.realmSlug == realmSlug; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   int get hashCode { | ||||||
|  |     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||||
|  |     hash = _SystemHash.combine(hash, realmSlug.hashCode); | ||||||
|  |  | ||||||
|  |     return _SystemHash.finish(hash); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||||
|  | // ignore: unused_element | ||||||
|  | mixin RealmChatRoomsRef on AutoDisposeFutureProviderRef<List<SnChatRoom>> { | ||||||
|  |   /// The parameter `realmSlug` of this provider. | ||||||
|  |   String get realmSlug; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _RealmChatRoomsProviderElement | ||||||
|  |     extends AutoDisposeFutureProviderElement<List<SnChatRoom>> | ||||||
|  |     with RealmChatRoomsRef { | ||||||
|  |   _RealmChatRoomsProviderElement(super.provider); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String get realmSlug => (origin as RealmChatRoomsProvider).realmSlug; | ||||||
|  | } | ||||||
|  |  | ||||||
| String _$realmMemberListNotifierHash() => | String _$realmMemberListNotifierHash() => | ||||||
|     r'b2e3eefc62a597f45df9470b2058fdda62f8853f'; |     r'b2e3eefc62a597f45df9470b2058fdda62f8853f'; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -46,6 +46,10 @@ class RealmListScreen extends HookConsumerWidget { | |||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         title: const Text('realms').tr(), |         title: const Text('realms').tr(), | ||||||
|         actions: [ |         actions: [ | ||||||
|  |           IconButton( | ||||||
|  |             icon: const Icon(Symbols.travel_explore), | ||||||
|  |             onPressed: () => context.push('/discovery/realms'), | ||||||
|  |           ), | ||||||
|           IconButton( |           IconButton( | ||||||
|             icon: Badge( |             icon: Badge( | ||||||
|               label: Text( |               label: Text( | ||||||
| @@ -66,7 +70,7 @@ class RealmListScreen extends HookConsumerWidget { | |||||||
|               showModalBottomSheet( |               showModalBottomSheet( | ||||||
|                 context: context, |                 context: context, | ||||||
|                 isScrollControlled: true, |                 isScrollControlled: true, | ||||||
|                 builder: (_) => _RealmInviteSheet(), |                 builder: (_) => const _RealmInviteSheet(), | ||||||
|               ); |               ); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
| @@ -74,7 +78,7 @@ class RealmListScreen extends HookConsumerWidget { | |||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|       floatingActionButton: FloatingActionButton( |       floatingActionButton: FloatingActionButton( | ||||||
|         heroTag: Key("realms-page-fab"), |         heroTag: const Key("realms-page-fab"), | ||||||
|         child: const Icon(Symbols.add), |         child: const Icon(Symbols.add), | ||||||
|         onPressed: () { |         onPressed: () { | ||||||
|           context.push('/realms/new').then((value) { |           context.push('/realms/new').then((value) { | ||||||
| @@ -106,7 +110,7 @@ class RealmListScreen extends HookConsumerWidget { | |||||||
|                           onTap: () { |                           onTap: () { | ||||||
|                             context.push('/realms/${value[item].slug}'); |                             context.push('/realms/${value[item].slug}'); | ||||||
|                           }, |                           }, | ||||||
|                           contentPadding: EdgeInsets.only( |                           contentPadding: const EdgeInsets.only( | ||||||
|                             left: 16, |                             left: 16, | ||||||
|                             right: 14, |                             right: 14, | ||||||
|                             top: 8, |                             top: 8, | ||||||
| @@ -158,6 +162,8 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|  |  | ||||||
|     final picture = useState<SnCloudFile?>(null); |     final picture = useState<SnCloudFile?>(null); | ||||||
|     final background = useState<SnCloudFile?>(null); |     final background = useState<SnCloudFile?>(null); | ||||||
|  |     final isPublic = useState(true); | ||||||
|  |     final isCommunity = useState(false); | ||||||
|  |  | ||||||
|     final slugController = useTextEditingController(); |     final slugController = useTextEditingController(); | ||||||
|     final nameController = useTextEditingController(); |     final nameController = useTextEditingController(); | ||||||
| @@ -174,6 +180,8 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|         slugController.text = realm.value!.slug; |         slugController.text = realm.value!.slug; | ||||||
|         nameController.text = realm.value!.name; |         nameController.text = realm.value!.name; | ||||||
|         descriptionController.text = realm.value!.description; |         descriptionController.text = realm.value!.description; | ||||||
|  |         isPublic.value = realm.value!.isPublic; | ||||||
|  |         isCommunity.value = realm.value!.isCommunity; | ||||||
|       } |       } | ||||||
|       return null; |       return null; | ||||||
|     }, [realm]); |     }, [realm]); | ||||||
| @@ -194,9 +202,9 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|         image: result, |         image: result, | ||||||
|         allowedAspectRatios: [ |         allowedAspectRatios: [ | ||||||
|           if (position == 'background') |           if (position == 'background') | ||||||
|             CropAspectRatio(height: 7, width: 16) |             const CropAspectRatio(height: 7, width: 16) | ||||||
|           else |           else | ||||||
|             CropAspectRatio(height: 1, width: 1), |             const CropAspectRatio(height: 1, width: 1), | ||||||
|         ], |         ], | ||||||
|       ); |       ); | ||||||
|       if (result == null) { |       if (result == null) { | ||||||
| @@ -252,6 +260,8 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|             'description': descriptionController.text, |             'description': descriptionController.text, | ||||||
|             'background_id': background.value?.id, |             'background_id': background.value?.id, | ||||||
|             'picture_id': picture.value?.id, |             'picture_id': picture.value?.id, | ||||||
|  |             'is_public': isPublic.value, | ||||||
|  |             'is_community': isCommunity.value, | ||||||
|           }, |           }, | ||||||
|           options: Options(method: slug == null ? 'POST' : 'PATCH'), |           options: Options(method: slug == null ? 'POST' : 'PATCH'), | ||||||
|         ); |         ); | ||||||
| @@ -284,9 +294,9 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|                     child: |                     child: | ||||||
|                         background.value != null |                         background.value != null | ||||||
|                             ? CloudFileWidget( |                             ? CloudFileWidget( | ||||||
|                               item: background.value!, |                                 item: background.value!, | ||||||
|                               fit: BoxFit.cover, |                                 fit: BoxFit.cover, | ||||||
|                             ) |                               ) | ||||||
|                             : const SizedBox.shrink(), |                             : const SizedBox.shrink(), | ||||||
|                   ), |                   ), | ||||||
|                   onTap: () { |                   onTap: () { | ||||||
| @@ -314,7 +324,6 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|             key: formKey, |             key: formKey, | ||||||
|             child: Column( |             child: Column( | ||||||
|               crossAxisAlignment: CrossAxisAlignment.start, |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|               spacing: 16, |  | ||||||
|               children: [ |               children: [ | ||||||
|                 TextFormField( |                 TextFormField( | ||||||
|                   controller: slugController, |                   controller: slugController, | ||||||
| @@ -325,12 +334,14 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|                   onTapOutside: |                   onTapOutside: | ||||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), |                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|  |                 const SizedBox(height: 16), | ||||||
|                 TextFormField( |                 TextFormField( | ||||||
|                   controller: nameController, |                   controller: nameController, | ||||||
|                   decoration: InputDecoration(labelText: 'name'.tr()), |                   decoration: InputDecoration(labelText: 'name'.tr()), | ||||||
|                   onTapOutside: |                   onTapOutside: | ||||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), |                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|  |                 const SizedBox(height: 16), | ||||||
|                 TextFormField( |                 TextFormField( | ||||||
|                   controller: descriptionController, |                   controller: descriptionController, | ||||||
|                   decoration: InputDecoration(labelText: 'description'.tr()), |                   decoration: InputDecoration(labelText: 'description'.tr()), | ||||||
| @@ -339,6 +350,20 @@ class EditRealmScreen extends HookConsumerWidget { | |||||||
|                   onTapOutside: |                   onTapOutside: | ||||||
|                       (_) => FocusManager.instance.primaryFocus?.unfocus(), |                       (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||||
|                 ), |                 ), | ||||||
|  |                 const SizedBox(height: 16), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   title: const Text('isPublic').tr(), | ||||||
|  |                   subtitle: const Text('isPublicHint').tr(), | ||||||
|  |                   value: isPublic.value, | ||||||
|  |                   onChanged: (value) => isPublic.value = value ?? false, | ||||||
|  |                 ), | ||||||
|  |                 CheckboxListTile( | ||||||
|  |                   title: const Text('isCommunity').tr(), | ||||||
|  |                   subtitle: const Text('isCommunityHint').tr(), | ||||||
|  |                   value: isCommunity.value, | ||||||
|  |                   onChanged: (value) => isCommunity.value = value ?? false, | ||||||
|  |                 ), | ||||||
|  |                 const SizedBox(height: 16), | ||||||
|                 Align( |                 Align( | ||||||
|                   alignment: Alignment.centerRight, |                   alignment: Alignment.centerRight, | ||||||
|                   child: TextButton.icon( |                   child: TextButton.icon( | ||||||
| @@ -410,47 +435,47 @@ class _RealmInviteSheet extends HookConsumerWidget { | |||||||
|             (items) => |             (items) => | ||||||
|                 items.isEmpty |                 items.isEmpty | ||||||
|                     ? Center( |                     ? Center( | ||||||
|                       child: |                         child: | ||||||
|                           Text( |                             Text( | ||||||
|                             'invitesEmpty', |                               'invitesEmpty', | ||||||
|                             textAlign: TextAlign.center, |                               textAlign: TextAlign.center, | ||||||
|                           ).tr(), |                             ).tr(), | ||||||
|                     ) |                       ) | ||||||
|                     : ListView.builder( |                     : ListView.builder( | ||||||
|                       shrinkWrap: true, |                         shrinkWrap: true, | ||||||
|                       itemCount: items.length, |                         itemCount: items.length, | ||||||
|                       itemBuilder: (context, index) { |                         itemBuilder: (context, index) { | ||||||
|                         final invite = items[index]; |                           final invite = items[index]; | ||||||
|                         return ListTile( |                           return ListTile( | ||||||
|                           leading: ProfilePictureWidget( |                             leading: ProfilePictureWidget( | ||||||
|                             fileId: invite.realm!.picture?.id, |                               fileId: invite.realm!.picture?.id, | ||||||
|                             fallbackIcon: Symbols.group, |                               fallbackIcon: Symbols.group, | ||||||
|                           ), |                             ), | ||||||
|                           title: Text(invite.realm!.name), |                             title: Text(invite.realm!.name), | ||||||
|                           subtitle: |                             subtitle: | ||||||
|                               Text( |                                 Text( | ||||||
|                                 invite.role >= 100 |                                   invite.role >= 100 | ||||||
|                                     ? 'permissionOwner' |                                       ? 'permissionOwner' | ||||||
|                                     : invite.role >= 50 |                                       : invite.role >= 50 | ||||||
|                                     ? 'permissionModerator' |                                       ? 'permissionModerator' | ||||||
|                                     : 'permissionMember', |                                       : 'permissionMember', | ||||||
|                               ).tr(), |                                 ).tr(), | ||||||
|                           trailing: Row( |                             trailing: Row( | ||||||
|                             mainAxisSize: MainAxisSize.min, |                               mainAxisSize: MainAxisSize.min, | ||||||
|                             children: [ |                               children: [ | ||||||
|                               IconButton( |                                 IconButton( | ||||||
|                                 icon: const Icon(Symbols.check), |                                   icon: const Icon(Symbols.check), | ||||||
|                                 onPressed: () => acceptInvite(invite), |                                   onPressed: () => acceptInvite(invite), | ||||||
|                               ), |                                 ), | ||||||
|                               IconButton( |                                 IconButton( | ||||||
|                                 icon: const Icon(Symbols.close), |                                   icon: const Icon(Symbols.close), | ||||||
|                                 onPressed: () => declineInvite(invite), |                                   onPressed: () => declineInvite(invite), | ||||||
|                               ), |                                 ), | ||||||
|                             ], |                               ], | ||||||
|                           ), |                             ), | ||||||
|                         ); |                           ); | ||||||
|                       }, |                         }, | ||||||
|                     ), |                       ), | ||||||
|         loading: () => const Center(child: CircularProgressIndicator()), |         loading: () => const Center(child: CircularProgressIndicator()), | ||||||
|         error: |         error: | ||||||
|             (error, _) => ResponseErrorWidget( |             (error, _) => ResponseErrorWidget( | ||||||
| @@ -460,4 +485,4 @@ class _RealmInviteSheet extends HookConsumerWidget { | |||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -32,7 +32,9 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | |||||||
|             var uri = notification.meta['action_uri'] as String; |             var uri = notification.meta['action_uri'] as String; | ||||||
|             if (uri.startsWith('/')) { |             if (uri.startsWith('/')) { | ||||||
|               // In-app routes |               // In-app routes | ||||||
|               rootNavigatorKey.currentContext?.push(notification.meta['action_uri']); |               rootNavigatorKey.currentContext?.push( | ||||||
|  |                 notification.meta['action_uri'], | ||||||
|  |               ); | ||||||
|             } else { |             } else { | ||||||
|               // External URLs |               // External URLs | ||||||
|               launchUrlString(uri); |               launchUrlString(uri); | ||||||
| @@ -46,8 +48,14 @@ StreamSubscription<WebSocketPacket> setupNotificationListener( | |||||||
|         padding: EdgeInsets.only( |         padding: EdgeInsets.only( | ||||||
|           left: 16, |           left: 16, | ||||||
|           right: 16, |           right: 16, | ||||||
|           // ignore: use_build_context_synchronously |           top: | ||||||
|           top: MediaQuery.of(context).padding.top + 24, |               (!kIsWeb && | ||||||
|  |                       (Platform.isMacOS || | ||||||
|  |                           Platform.isWindows || | ||||||
|  |                           Platform.isLinux)) | ||||||
|  |                   ? 24 | ||||||
|  |                   // ignore: use_build_context_synchronously | ||||||
|  |                   : MediaQuery.of(context).padding.top + 8, | ||||||
|           bottom: 16, |           bottom: 16, | ||||||
|         ), |         ), | ||||||
|       ); |       ); | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; | |||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/services/notify.dart'; | import 'package:island/services/notify.dart'; | ||||||
| import 'package:island/services/sharing_intent.dart'; | import 'package:island/services/sharing_intent.dart'; | ||||||
|  | import 'package:island/widgets/tour/tour.dart'; | ||||||
|  |  | ||||||
| class AppWrapper extends HookConsumerWidget { | class AppWrapper extends HookConsumerWidget { | ||||||
|   final Widget child; |   final Widget child; | ||||||
| @@ -24,6 +25,6 @@ class AppWrapper extends HookConsumerWidget { | |||||||
|       }; |       }; | ||||||
|     }, const []); |     }, const []); | ||||||
|  |  | ||||||
|     return child; |     return TourTriggerWidget(child: child); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -233,16 +233,27 @@ class MessageItem extends HookConsumerWidget { | |||||||
|                           if (remoteMessage.meta['embeds'] != null) |                           if (remoteMessage.meta['embeds'] != null) | ||||||
|                             ...((remoteMessage.meta['embeds'] as List<dynamic>) |                             ...((remoteMessage.meta['embeds'] as List<dynamic>) | ||||||
|                                 .where((embed) => embed['Type'] == 'link') |                                 .where((embed) => embed['Type'] == 'link') | ||||||
|                                 .map((embed) => SnEmbedLink.fromJson(embed as Map<String, dynamic>)) |                                 .map( | ||||||
|                                 .map((link) => LayoutBuilder( |                                   (embed) => SnEmbedLink.fromJson( | ||||||
|                                       builder: (context, constraints) { |                                     embed as Map<String, dynamic>, | ||||||
|                                         return EmbedLinkWidget( |                                   ), | ||||||
|                                           link: link, |                                 ) | ||||||
|                                           maxWidth: math.min(constraints.maxWidth, 480), |                                 .map( | ||||||
|                                           margin: const EdgeInsets.symmetric(vertical: 4), |                                   (link) => LayoutBuilder( | ||||||
|                                         ); |                                     builder: (context, constraints) { | ||||||
|                                       }, |                                       return EmbedLinkWidget( | ||||||
|                                     )) |                                         link: link, | ||||||
|  |                                         maxWidth: math.min( | ||||||
|  |                                           constraints.maxWidth, | ||||||
|  |                                           480, | ||||||
|  |                                         ), | ||||||
|  |                                         margin: const EdgeInsets.symmetric( | ||||||
|  |                                           vertical: 4, | ||||||
|  |                                         ), | ||||||
|  |                                       ); | ||||||
|  |                                     }, | ||||||
|  |                                   ), | ||||||
|  |                                 ) | ||||||
|                                 .toList()), |                                 .toList()), | ||||||
|                           if (progress != null && progress!.isNotEmpty) |                           if (progress != null && progress!.isNotEmpty) | ||||||
|                             Column( |                             Column( | ||||||
| @@ -482,7 +493,11 @@ class _MessageItemContent extends StatelessWidget { | |||||||
|         ); |         ); | ||||||
|       case 'text': |       case 'text': | ||||||
|       default: |       default: | ||||||
|         return MarkdownTextContent(content: item.content!, isSelectable: true); |         return MarkdownTextContent( | ||||||
|  |           content: item.content!, | ||||||
|  |           isSelectable: true, | ||||||
|  |           linesMargin: EdgeInsets.zero, | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -186,13 +186,7 @@ class CloudFileZoomIn extends HookConsumerWidget { | |||||||
|     Future<void> saveToGallery() async { |     Future<void> saveToGallery() async { | ||||||
|       try { |       try { | ||||||
|         // Show loading indicator |         // Show loading indicator | ||||||
|         final scaffold = ScaffoldMessenger.of(context); |         showSnackBar('Saving image to gallery...'); | ||||||
|         scaffold.showSnackBar( |  | ||||||
|           const SnackBar( |  | ||||||
|             content: Text('Saving image to gallery...'), |  | ||||||
|             duration: Duration(seconds: 1), |  | ||||||
|           ), |  | ||||||
|         ); |  | ||||||
|  |  | ||||||
|         // Get the image URL |         // Get the image URL | ||||||
|         final client = ref.watch(apiClientProvider); |         final client = ref.watch(apiClientProvider); | ||||||
| @@ -209,12 +203,7 @@ class CloudFileZoomIn extends HookConsumerWidget { | |||||||
|         await Gal.putImage(filePath, album: 'Solar Network'); |         await Gal.putImage(filePath, album: 'Solar Network'); | ||||||
|  |  | ||||||
|         // Show success message |         // Show success message | ||||||
|         scaffold.showSnackBar( |         showSnackBar('Image saved to gallery'); | ||||||
|           const SnackBar( |  | ||||||
|             content: Text('Image saved to gallery'), |  | ||||||
|             duration: Duration(seconds: 2), |  | ||||||
|           ), |  | ||||||
|         ); |  | ||||||
|       } catch (e) { |       } catch (e) { | ||||||
|         showErrorAlert(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, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										103
									
								
								lib/widgets/realm/realm_card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								lib/widgets/realm/realm_card.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/models/realm.dart'; | ||||||
|  | import 'package:island/widgets/content/cloud_files.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  |  | ||||||
|  | class RealmCard extends ConsumerWidget { | ||||||
|  |   final SnRealm realm; | ||||||
|  |   final double? maxWidth; | ||||||
|  |  | ||||||
|  |   const RealmCard({super.key, required this.realm, this.maxWidth}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     Widget imageWidget; | ||||||
|  |     if (realm.picture != null) { | ||||||
|  |       imageWidget = | ||||||
|  |           imageWidget = CloudImageWidget( | ||||||
|  |             file: realm.background, | ||||||
|  |             fit: BoxFit.cover, | ||||||
|  |           ); | ||||||
|  |     } else { | ||||||
|  |       imageWidget = ColoredBox( | ||||||
|  |         color: Theme.of(context).colorScheme.secondaryContainer, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Widget card = Card( | ||||||
|  |       clipBehavior: Clip.antiAlias, | ||||||
|  |       child: InkWell( | ||||||
|  |         onTap: () { | ||||||
|  |           context.push('/realms/${realm.slug}'); | ||||||
|  |         }, | ||||||
|  |         child: AspectRatio( | ||||||
|  |           aspectRatio: 16 / 7, | ||||||
|  |           child: Stack( | ||||||
|  |             fit: StackFit.expand, | ||||||
|  |             children: [ | ||||||
|  |               imageWidget, | ||||||
|  |               Positioned( | ||||||
|  |                 bottom: 0, | ||||||
|  |                 left: 0, | ||||||
|  |                 right: 0, | ||||||
|  |                 child: Container( | ||||||
|  |                   decoration: BoxDecoration( | ||||||
|  |                     gradient: LinearGradient( | ||||||
|  |                       begin: Alignment.bottomCenter, | ||||||
|  |                       end: Alignment.topCenter, | ||||||
|  |                       colors: [ | ||||||
|  |                         Colors.black.withOpacity(0.7), | ||||||
|  |                         Colors.transparent, | ||||||
|  |                       ], | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                   padding: const EdgeInsets.all(8), | ||||||
|  |                   child: Column( | ||||||
|  |                     crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|  |                     children: [ | ||||||
|  |                       Container( | ||||||
|  |                         decoration: BoxDecoration( | ||||||
|  |                           shape: BoxShape.circle, | ||||||
|  |                           boxShadow: [ | ||||||
|  |                             BoxShadow( | ||||||
|  |                               color: Colors.black.withOpacity(0.5), | ||||||
|  |                               blurRadius: 4, | ||||||
|  |                               offset: const Offset(0, 2), | ||||||
|  |                             ), | ||||||
|  |                           ], | ||||||
|  |                         ), | ||||||
|  |                         child: ProfilePictureWidget( | ||||||
|  |                           file: realm.picture, | ||||||
|  |                           fallbackIcon: Symbols.group, | ||||||
|  |                           radius: 12, | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                       const Gap(2), | ||||||
|  |                       Text( | ||||||
|  |                         realm.name, | ||||||
|  |                         style: Theme.of(context).textTheme.titleSmall?.copyWith( | ||||||
|  |                           color: Colors.white, | ||||||
|  |                           fontWeight: FontWeight.bold, | ||||||
|  |                         ), | ||||||
|  |                         maxLines: 2, | ||||||
|  |                         overflow: TextOverflow.ellipsis, | ||||||
|  |                       ), | ||||||
|  |                     ], | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return ConstrainedBox( | ||||||
|  |       constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), | ||||||
|  |       child: card, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										88
									
								
								lib/widgets/realm/realm_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								lib/widgets/realm/realm_list.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/models/realm.dart'; | ||||||
|  | import 'package:island/pods/network.dart'; | ||||||
|  | import 'package:island/widgets/realm/realm_card.dart'; | ||||||
|  | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
|  | import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; | ||||||
|  |  | ||||||
|  | part 'realm_list.g.dart'; | ||||||
|  |  | ||||||
|  | @riverpod | ||||||
|  | class RealmListNotifier extends _$RealmListNotifier | ||||||
|  |     with CursorPagingNotifierMixin<SnRealm> { | ||||||
|  |   static const int _pageSize = 20; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<CursorPagingData<SnRealm>> build(String? query) { | ||||||
|  |     return fetch(cursor: null, query: query); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Future<CursorPagingData<SnRealm>> fetch({ | ||||||
|  |     required String? cursor, | ||||||
|  |     String? query, | ||||||
|  |   }) async { | ||||||
|  |     final client = ref.read(apiClientProvider); | ||||||
|  |     final offset = cursor == null ? 0 : int.parse(cursor); | ||||||
|  |  | ||||||
|  |     final queryParams = { | ||||||
|  |       'offset': offset, | ||||||
|  |       'take': _pageSize, | ||||||
|  |       if (query != null && query.isNotEmpty) 'query': query, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     final response = await client.get( | ||||||
|  |       '/discovery/realms', | ||||||
|  |       queryParameters: queryParams, | ||||||
|  |     ); | ||||||
|  |     final total = int.parse(response.headers.value('X-Total') ?? '0'); | ||||||
|  |     final List<dynamic> data = response.data; | ||||||
|  |     final realms = data.map((json) => SnRealm.fromJson(json)).toList(); | ||||||
|  |  | ||||||
|  |     final hasMore = offset + realms.length < total; | ||||||
|  |     final nextCursor = hasMore ? (offset + realms.length).toString() : null; | ||||||
|  |  | ||||||
|  |     return CursorPagingData( | ||||||
|  |       items: realms, | ||||||
|  |       hasMore: hasMore, | ||||||
|  |       nextCursor: nextCursor, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SliverRealmList extends HookConsumerWidget { | ||||||
|  |   const SliverRealmList({super.key, this.query}); | ||||||
|  |  | ||||||
|  |   final String? query; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     return PagingHelperSliverView( | ||||||
|  |       provider: realmListNotifierProvider(query), | ||||||
|  |       futureRefreshable: realmListNotifierProvider(query).future, | ||||||
|  |       notifierRefreshable: realmListNotifierProvider(query).notifier, | ||||||
|  |       contentBuilder: | ||||||
|  |           (data, widgetCount, endItemView) => SliverList.separated( | ||||||
|  |             itemCount: widgetCount, | ||||||
|  |             itemBuilder: (context, index) { | ||||||
|  |               if (index == widgetCount - 1) { | ||||||
|  |                 return endItemView; | ||||||
|  |               } | ||||||
|  |  | ||||||
|  |               final realm = data.items[index]; | ||||||
|  |  | ||||||
|  |               return Padding( | ||||||
|  |                 padding: const EdgeInsets.symmetric( | ||||||
|  |                   horizontal: 16, | ||||||
|  |                   vertical: 8, | ||||||
|  |                 ), | ||||||
|  |                 child: RealmCard(realm: realm), | ||||||
|  |               ); | ||||||
|  |             }, | ||||||
|  |             separatorBuilder: (_, _) => const Gap(8), | ||||||
|  |           ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										179
									
								
								lib/widgets/realm/realm_list.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								lib/widgets/realm/realm_list.g.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | |||||||
|  | // GENERATED CODE - DO NOT MODIFY BY HAND | ||||||
|  |  | ||||||
|  | part of 'realm_list.dart'; | ||||||
|  |  | ||||||
|  | // ************************************************************************** | ||||||
|  | // RiverpodGenerator | ||||||
|  | // ************************************************************************** | ||||||
|  |  | ||||||
|  | String _$realmListNotifierHash() => r'02dee373a5609a5617b04ffec395d09dea7ae070'; | ||||||
|  |  | ||||||
|  | /// Copied from Dart SDK | ||||||
|  | class _SystemHash { | ||||||
|  |   _SystemHash._(); | ||||||
|  |  | ||||||
|  |   static int combine(int hash, int value) { | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + value); | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); | ||||||
|  |     return hash ^ (hash >> 6); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static int finish(int hash) { | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); | ||||||
|  |     // ignore: parameter_assignments | ||||||
|  |     hash = hash ^ (hash >> 11); | ||||||
|  |     return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | abstract class _$RealmListNotifier | ||||||
|  |     extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnRealm>> { | ||||||
|  |   late final String? query; | ||||||
|  |  | ||||||
|  |   FutureOr<CursorPagingData<SnRealm>> build(String? query); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [RealmListNotifier]. | ||||||
|  | @ProviderFor(RealmListNotifier) | ||||||
|  | const realmListNotifierProvider = RealmListNotifierFamily(); | ||||||
|  |  | ||||||
|  | /// See also [RealmListNotifier]. | ||||||
|  | class RealmListNotifierFamily | ||||||
|  |     extends Family<AsyncValue<CursorPagingData<SnRealm>>> { | ||||||
|  |   /// See also [RealmListNotifier]. | ||||||
|  |   const RealmListNotifierFamily(); | ||||||
|  |  | ||||||
|  |   /// See also [RealmListNotifier]. | ||||||
|  |   RealmListNotifierProvider call(String? query) { | ||||||
|  |     return RealmListNotifierProvider(query); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   RealmListNotifierProvider getProviderOverride( | ||||||
|  |     covariant RealmListNotifierProvider provider, | ||||||
|  |   ) { | ||||||
|  |     return call(provider.query); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _dependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get dependencies => _dependencies; | ||||||
|  |  | ||||||
|  |   static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Iterable<ProviderOrFamily>? get allTransitiveDependencies => | ||||||
|  |       _allTransitiveDependencies; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? get name => r'realmListNotifierProvider'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// See also [RealmListNotifier]. | ||||||
|  | class RealmListNotifierProvider | ||||||
|  |     extends | ||||||
|  |         AutoDisposeAsyncNotifierProviderImpl< | ||||||
|  |           RealmListNotifier, | ||||||
|  |           CursorPagingData<SnRealm> | ||||||
|  |         > { | ||||||
|  |   /// See also [RealmListNotifier]. | ||||||
|  |   RealmListNotifierProvider(String? query) | ||||||
|  |     : this._internal( | ||||||
|  |         () => RealmListNotifier()..query = query, | ||||||
|  |         from: realmListNotifierProvider, | ||||||
|  |         name: r'realmListNotifierProvider', | ||||||
|  |         debugGetCreateSourceHash: | ||||||
|  |             const bool.fromEnvironment('dart.vm.product') | ||||||
|  |                 ? null | ||||||
|  |                 : _$realmListNotifierHash, | ||||||
|  |         dependencies: RealmListNotifierFamily._dependencies, | ||||||
|  |         allTransitiveDependencies: | ||||||
|  |             RealmListNotifierFamily._allTransitiveDependencies, | ||||||
|  |         query: query, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |   RealmListNotifierProvider._internal( | ||||||
|  |     super._createNotifier, { | ||||||
|  |     required super.name, | ||||||
|  |     required super.dependencies, | ||||||
|  |     required super.allTransitiveDependencies, | ||||||
|  |     required super.debugGetCreateSourceHash, | ||||||
|  |     required super.from, | ||||||
|  |     required this.query, | ||||||
|  |   }) : super.internal(); | ||||||
|  |  | ||||||
|  |   final String? query; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   FutureOr<CursorPagingData<SnRealm>> runNotifierBuild( | ||||||
|  |     covariant RealmListNotifier notifier, | ||||||
|  |   ) { | ||||||
|  |     return notifier.build(query); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Override overrideWith(RealmListNotifier Function() create) { | ||||||
|  |     return ProviderOverride( | ||||||
|  |       origin: this, | ||||||
|  |       override: RealmListNotifierProvider._internal( | ||||||
|  |         () => create()..query = query, | ||||||
|  |         from: from, | ||||||
|  |         name: null, | ||||||
|  |         dependencies: null, | ||||||
|  |         allTransitiveDependencies: null, | ||||||
|  |         debugGetCreateSourceHash: null, | ||||||
|  |         query: query, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   AutoDisposeAsyncNotifierProviderElement< | ||||||
|  |     RealmListNotifier, | ||||||
|  |     CursorPagingData<SnRealm> | ||||||
|  |   > | ||||||
|  |   createElement() { | ||||||
|  |     return _RealmListNotifierProviderElement(this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   bool operator ==(Object other) { | ||||||
|  |     return other is RealmListNotifierProvider && other.query == query; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   int get hashCode { | ||||||
|  |     var hash = _SystemHash.combine(0, runtimeType.hashCode); | ||||||
|  |     hash = _SystemHash.combine(hash, query.hashCode); | ||||||
|  |  | ||||||
|  |     return _SystemHash.finish(hash); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Deprecated('Will be removed in 3.0. Use Ref instead') | ||||||
|  | // ignore: unused_element | ||||||
|  | mixin RealmListNotifierRef | ||||||
|  |     on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnRealm>> { | ||||||
|  |   /// The parameter `query` of this provider. | ||||||
|  |   String? get query; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _RealmListNotifierProviderElement | ||||||
|  |     extends | ||||||
|  |         AutoDisposeAsyncNotifierProviderElement< | ||||||
|  |           RealmListNotifier, | ||||||
|  |           CursorPagingData<SnRealm> | ||||||
|  |         > | ||||||
|  |     with RealmListNotifierRef { | ||||||
|  |   _RealmListNotifierProviderElement(super.provider); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   String? get query => (origin as RealmListNotifierProvider).query; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ignore_for_file: type=lint | ||||||
|  | // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package | ||||||
							
								
								
									
										20
									
								
								lib/widgets/realm/realm_tile.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lib/widgets/realm/realm_tile.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/models/realm.dart'; | ||||||
|  | import 'package:island/widgets/content/cloud_files.dart'; | ||||||
|  |  | ||||||
|  | class RealmTile extends HookConsumerWidget { | ||||||
|  |   final SnRealm realm; | ||||||
|  |   const RealmTile({super.key, required this.realm}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     return ListTile( | ||||||
|  |       leading: ProfilePictureWidget(file: realm.picture), | ||||||
|  |       title: Text(realm.name), | ||||||
|  |       subtitle: Text(realm.description), | ||||||
|  |       onTap: () => context.push('/realms/${realm.slug}'), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -13,6 +13,7 @@ import 'package:island/pods/network.dart'; | |||||||
| import 'package:island/pods/config.dart'; | import 'package:island/pods/config.dart'; | ||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
| import 'package:island/services/file.dart'; | import 'package:island/services/file.dart'; | ||||||
|  | import 'package:mime/mime.dart'; | ||||||
|  |  | ||||||
| import 'dart:io'; | import 'dart:io'; | ||||||
| import 'package:path/path.dart' as path; | import 'package:path/path.dart' as path; | ||||||
| @@ -149,9 +150,9 @@ class _ShareSheetState extends ConsumerState<ShareSheet> { | |||||||
|         case ShareContentType.file: |         case ShareContentType.file: | ||||||
|           if (widget.content.files != null) { |           if (widget.content.files != null) { | ||||||
|             // Convert XFiles to UniversalFiles |             // Convert XFiles to UniversalFiles | ||||||
|             for (final xFile in widget.content.files!) { |             for (final file in widget.content.files!) { | ||||||
|               final file = File(xFile.path); |               var mimeType = file.mimeType; | ||||||
|               final mimeType = xFile.mimeType; |               mimeType ??= lookupMimeType(file.path); | ||||||
|  |  | ||||||
|               UniversalFileType fileType; |               UniversalFileType fileType; | ||||||
|               if (mimeType?.startsWith('image/') == true) { |               if (mimeType?.startsWith('image/') == true) { | ||||||
|   | |||||||
| @@ -49,6 +49,8 @@ PODS: | |||||||
|     - OrderedSet (~> 6.0.3) |     - OrderedSet (~> 6.0.3) | ||||||
|   - flutter_platform_alert (0.0.1): |   - flutter_platform_alert (0.0.1): | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
|  |   - flutter_secure_storage_macos (6.1.3): | ||||||
|  |     - FlutterMacOS | ||||||
|   - flutter_timezone (0.1.0): |   - flutter_timezone (0.1.0): | ||||||
|     - FlutterMacOS |     - FlutterMacOS | ||||||
|   - flutter_udid (0.0.1): |   - flutter_udid (0.0.1): | ||||||
| @@ -171,6 +173,7 @@ DEPENDENCIES: | |||||||
|   - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) |   - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) | ||||||
|   - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/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_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_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) | ||||||
|   - flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`) |   - flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`) | ||||||
|   - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/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 |     :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos | ||||||
|   flutter_platform_alert: |   flutter_platform_alert: | ||||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos |     :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: |   flutter_timezone: | ||||||
|     :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos |     :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos | ||||||
|   flutter_udid: |   flutter_udid: | ||||||
| @@ -295,6 +300,7 @@ SPEC CHECKSUMS: | |||||||
|   FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 |   FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4 | ||||||
|   flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d |   flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d | ||||||
|   flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 |   flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 | ||||||
|  |   flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 | ||||||
|   flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8 |   flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8 | ||||||
|   flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495 |   flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495 | ||||||
|   flutter_webrtc: a7eeb54859e672228c28f4b48b1fb61561976ea3 |   flutter_webrtc: a7eeb54859e672228c28f4b48b1fb61561976ea3 | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -1470,7 +1470,7 @@ packages: | |||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.16.0" |     version: "1.16.0" | ||||||
|   mime: |   mime: | ||||||
|     dependency: transitive |     dependency: "direct main" | ||||||
|     description: |     description: | ||||||
|       name: mime |       name: mime | ||||||
|       sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" |       sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" | ||||||
| @@ -1785,10 +1785,10 @@ packages: | |||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: record_linux |       name: record_linux | ||||||
|       sha256: "29e7735b05c1944bb6c9b72a36c08d4a1b24117e712d6a9523c003bde12bf484" |       sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95" | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "1.1.0" |     version: "1.1.1" | ||||||
|   record_macos: |   record_macos: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
| @@ -2254,10 +2254,10 @@ packages: | |||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|       name: synchronized |       name: synchronized | ||||||
|       sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" |       sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 | ||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "3.3.1" |     version: "3.4.0" | ||||||
|   table_calendar: |   table_calendar: | ||||||
|     dependency: "direct main" |     dependency: "direct main" | ||||||
|     description: |     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 | # 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 | # 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. | # 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: | environment: | ||||||
|   sdk: ^3.7.2 |   sdk: ^3.7.2 | ||||||
| @@ -123,9 +123,10 @@ dependencies: | |||||||
|   receive_sharing_intent: ^1.8.1 |   receive_sharing_intent: ^1.8.1 | ||||||
|   top_snackbar_flutter: ^3.3.0 |   top_snackbar_flutter: ^3.3.0 | ||||||
|   textfield_tags: |   textfield_tags: | ||||||
|    git: |     git: | ||||||
|       url: https://github.com/lionelmennig/textfield_tags.git |       url: https://github.com/lionelmennig/textfield_tags.git | ||||||
|       ref: fixes/allow-controller-re-registration |       ref: fixes/allow-controller-re-registration | ||||||
|  |   mime: ^2.0.0 | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   flutter_test: |   flutter_test: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user