Compare commits

..

12 Commits

Author SHA1 Message Date
9e8f6d57df 🚀 Launch 3.0.0+108 2025-06-28 01:53:07 +08:00
79227a12e2 Android notification extension 2025-06-28 01:28:44 +08:00
a23dcfe702 🐛 Bug fixes android sharing, ios notifications and more 2025-06-28 01:10:44 +08:00
243ecb3f71 Searchable realms 2025-06-28 00:47:03 +08:00
b8dec9f798 Joindable chat, detailed realms, discovery mixed into explore
🐛 bunch of bugs fixes
2025-06-28 00:24:43 +08:00
536375729f 💄 Optimize the notification snackbar position 2025-06-27 22:18:16 +08:00
5939a1dc5b Can make the chat and realm public 2025-06-27 21:52:57 +08:00
9d115a5712 Realm discovery and more detailed realm 2025-06-27 21:10:53 +08:00
f511612a53 Chat rooms in realm detail page 2025-06-27 17:54:29 +08:00
180fbcc558 Join the realm by user own 2025-06-27 17:30:42 +08:00
047cb9dc0d Realms discovery in explore 2025-06-27 02:56:58 +08:00
786f851a97 🐛 Fixes on post route 2025-06-27 02:35:06 +08:00
49 changed files with 1789 additions and 408 deletions

View File

@ -57,6 +57,9 @@ android {
dependencies {
implementation("com.google.android.material:material:1.12.0")
implementation("com.github.bumptech.glide:glide:4.16.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.firebase:firebase-messaging-ktx")
}
flutter {

View File

@ -46,12 +46,37 @@
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/*" />
</intent-filter>
</activity>
@ -70,6 +95,19 @@
</intent-filter>
</activity>
<receiver
android:name=".receiver.ReplyReceiver"
android:enabled="true"
android:exported="false" />
<service
android:name=".service.MessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="dev.solsynth.solian.provider"

View File

@ -0,0 +1,48 @@
package dev.solsynth.solian.network
import android.content.Context
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
class ApiClient(private val context: Context) {
private val client = OkHttpClient()
fun sendMessage(roomId: String, content: String, repliedMessageId: String, callback: () -> Unit) {
val prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
val token = prefs.getString("flutter.token", null)
val serverUrl = prefs.getString("flutter.serverUrl", null)
if (token == null || serverUrl == null) {
return
}
val url = "$serverUrl/chat/$roomId/messages"
val json = JSONObject()
json.put("content", content)
json.put("replied_message_id", repliedMessageId)
val requestBody = json.toString().toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Authorization", "AtField $token")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
// Handle failure
callback()
}
override fun onResponse(call: Call, response: Response) {
// Handle success
callback()
}
})
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -0,0 +1,101 @@
package dev.solsynth.solian.service
import android.app.PendingIntent
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dev.solsynth.solian.MainActivity
import dev.solsynth.solian.receiver.ReplyReceiver
import org.json.JSONObject
class MessagingService: FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
val type = remoteMessage.data["type"]
if (type == "messages.new") {
handleMessageNotification(remoteMessage)
} else {
// Handle other notification types
}
}
private fun handleMessageNotification(remoteMessage: RemoteMessage) {
val data = remoteMessage.data
val metaString = data["meta"] ?: return
val meta = JSONObject(metaString)
val pfp = meta.optString("pfp", null)
val roomId = meta.optString("room_id", null)
val messageId = meta.optString("message_id", null)
val notificationId = System.currentTimeMillis().toInt()
val replyLabel = "Reply"
val remoteInput = RemoteInput.Builder("key_text_reply")
.setLabel(replyLabel)
.build()
val replyIntent = Intent(this, ReplyReceiver::class.java).apply {
putExtra("room_id", roomId)
putExtra("message_id", messageId)
putExtra("notification_id", notificationId)
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val replyPendingIntent = PendingIntent.getBroadcast(
applicationContext,
notificationId,
replyIntent,
pendingIntentFlags
)
val action = NotificationCompat.Action.Builder(
android.R.drawable.ic_menu_send,
replyLabel,
replyPendingIntent
)
.addRemoteInput(remoteInput)
.build()
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
val notificationBuilder = NotificationCompat.Builder(this, "messages")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(remoteMessage.notification?.title)
.setContentText(remoteMessage.notification?.body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.addAction(action)
if (pfp != null) {
Glide.with(applicationContext)
.asBitmap()
.load(pfp)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
notificationBuilder.setLargeIcon(resource)
NotificationManagerCompat.from(applicationContext).notify(notificationId, notificationBuilder.build())
}
override fun onLoadCleared(placeholder: Drawable?) {}
})
} else {
NotificationManagerCompat.from(this).notify(notificationId, notificationBuilder.build())
}
}
}

View File

@ -89,30 +89,14 @@
"authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.",
"authFactorPin": "Pin Code",
"authFactorPinDescription": "It consists of 6 digits. It cannot be used to log in. When performing some dangerous operations, the system will ask you to enter this PIN for confirmation.",
"realms": "Realms",
"createRealm": "Create a Realm",
"createRealmHint": "Meet friends with same interests, build communities, and more.",
"editRealm": "Edit Realm",
"deleteRealm": "Delete Realm",
"deleteRealmHint": "Are you sure to delete this realm? This will also deleted all the channels, publishers, and posts under this realm.",
"explore": "Explore",
"exploreFilterSubscriptions": "Subscriptions",
"exploreFilterFriends": "Friends",
"discover": "Discover",
"account": "Account",
"name": "Name",
"slug": "Slug",
"slugHint": "The slug will be used in the URL to access this resource, it should be unique and URL safe.",
"createChatRoom": "Create a Room",
"editChatRoom": "Edit Room",
"deleteChatRoom": "Delete Room",
"deleteChatRoomHint": "Are you sure to delete this room? This action cannot be undone.",
"chat": "Chat",
"chatTabAll": "All",
"chatTabDirect": "Direct Messages",
"chatTabGroup": "Group Chats",
"chatMessageHint": "Message in {}",
"chatDirectMessageHint": "Message to {}",
"directMessage": "Direct Message",
"loading": "Loading...",
"descriptionNone": "No description yet.",
"invites": "Invites",
@ -247,7 +231,6 @@
"uploadingProgress": "Uploading {} of {}",
"uploadAll": "Upload All",
"stickerCopyPlaceholder": "Copy Placeholder",
"realmSelection": "Select a Realm",
"individual": "Individual",
"firstPostBadgeName": "First Post",
"firstPostBadgeDescription": "Created your first post on Solar Network",
@ -303,10 +286,6 @@
"levelingProgressExperience": "{} EXP",
"levelingProgressLevel": "Level {}",
"fileUploadingProgress": "Uploading file #{}: {}%",
"removeChatMember": "Remove Chat Room Member",
"removeChatMemberHint": "Are you sure to remove this member from the room?",
"removeRealmMember": "Remove Realm Member",
"removeRealmMemberHint": "Are you sure to remove this member from the realm?",
"memberRole": "Member Role",
"memberRoleHint": "Greater number has higher permission.",
"memberRoleEdit": "Edit role for @{}",
@ -314,10 +293,6 @@
"openLinkConfirmDescription": "You're going to leave the Solar Network and open the link ({}) in your browser. It is not related to Solar Network. Beware of phishing and scams.",
"brokenLink": "Unable open link {}... It might be broken or missing uri parts...",
"copyToClipboard": "Copy to clipboard",
"leaveChatRoom": "Leave Chat Room",
"leaveChatRoomHint": "Are you sure to leave this chat room?",
"leaveRealm": "Leave Realm",
"leaveRealmHint": "Are you sure to leave this realm?",
"walletNotFound": "Wallet not found",
"walletCreateHint": "You don't have a wallet yet. Create one to start using the Solar Network eWallet.",
"walletCreate": "Create a Wallet",
@ -329,12 +304,6 @@
"settingsBackgroundImageClear": "Clear Background Image",
"settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image",
"messageNone": "No content to display",
"unreadMessages": {
"one": "{} unread message",
"other": "{} unread messages"
},
"chatBreakNone": "None",
"settingsRealmCompactView": "Compact Realm View",
"settingsMixedFeed": "Mixed Feed",
"settingsAutoTranslate": "Auto Translate",
"settingsHideBottomNav": "Hide Bottom Navigation",
@ -377,7 +346,6 @@
"postVisibilityUnlisted": "Unlisted",
"postVisibilityPrivate": "Private",
"postTruncated": "Content truncated, tap to view full post",
"copyMessage": "Copy Message",
"authFactor": "Authentication Factor",
"authFactorDelete": "Delete the Factor",
"authFactorDeleteHint": "Are you sure you want to delete this authentication factor? This action cannot be undone.",
@ -410,10 +378,6 @@
"authDeviceLabelHint": "Enter a name for this device",
"authDeviceSwipeEditHint": "Swipe left to edit label",
"authDeviceSwipeLogoutHint": "Swipe right to logout device",
"typingHint": {
"one": "{} is typing...",
"other": "{} are typing..."
},
"settingsAppearance": "Appearance",
"settingsServer": "Server",
"settingsBehavior": "Behavior",
@ -475,21 +439,6 @@
"contactMethodSetPrimary": "Set as Primary",
"contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications",
"contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone.",
"chatNotifyLevel": "Notify Level",
"chatNotifyLevelDescription": "Decide how many notifications you will receive.",
"chatNotifyLevelAll": "All",
"chatNotifyLevelMention": "Mentions",
"chatNotifyLevelNone": "None",
"chatNotifyLevelUpdated": "The notify level has been updated to {}.",
"chatBreak": "Take a Break",
"chatBreakDescription": "Set a time, before that time, your notification level will be metions only, to take a break of the current topic they're talking about.",
"chatBreakClear": "Clear the break time",
"chatBreakHour": "{} break",
"chatBreakDay": "{} day break",
"chatBreakSet": "Break set for {}",
"chatBreakCleared": "Chat break has been cleared.",
"chatBreakCustom": "Custom duration",
"chatBreakEnterMinutes": "Enter minutes",
"firstName": "First Name",
"middleName": "Middle Name",
"lastName": "Last Name",
@ -571,29 +520,17 @@
"quickActions": "Quick Actions",
"post": "Post",
"copy": "Copy",
"sendToChat": "Send to Chat",
"failedToShareToPost": "Failed to share to post: {}",
"shareToChatComingSoon": "Share to chat functionality coming soon",
"failedToShareToChat": "Failed to share to chat: {}",
"shareToSpecificChatComingSoon": "Share to {} coming soon",
"directChat": "Direct Chat",
"systemShareComingSoon": "System share functionality coming soon",
"failedToShareToSystem": "Failed to share to system: {}",
"failedToCopy": "Failed to copy: {}",
"noChatRoomsAvailable": "No chat rooms available",
"failedToLoadChats": "Failed to load chats",
"contentToShare": "Content to share:",
"unknownChat": "Unknown Chat",
"addAdditionalMessage": "Add additional message...",
"uploadingFiles": "Uploading files...",
"sharedSuccessfully": "Shared successfully!",
"shareSuccess": "Shared successfully!",
"shareToSpecificChatSuccess": "Shared to {} successfully!",
"wouldYouLikeToGoToChat": "Would you like to go to the chat?",
"no": "No",
"yes": "Yes",
"navigateToChat": "Navigate to Chat",
"wouldYouLikeToNavigateToChat": "Would you like to navigate to the chat?",
"abuseReport": "Report",
"abuseReportTitle": "Report Content",
"abuseReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.",
@ -619,5 +556,13 @@
"tags": "Tags",
"tagsHint": "Enter tags, separated by commas",
"categories": "Categories",
"categoriesHint": "Enter categories, separated by commas"
"categoriesHint": "Enter categories, separated by commas",
"chatNotJoined": "You have not joined this chat yet.",
"chatUnableJoin": "You can't join this chat due to it's access control settings.",
"chatJoin": "Join the Chat",
"realmJoin": "Join the Realm",
"realmJoinSuccess": "Successfully joined the realm.",
"discoverRealms": "Discover Realms",
"discoverPublishers": "Discover Publishers",
"search": "Search"
}

View File

@ -857,7 +857,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -900,7 +900,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -940,7 +940,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -979,7 +979,7 @@
INFOPLIST_FILE = SolianNotificationService/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1021,7 +1021,7 @@
INFOPLIST_FILE = SolianNotificationService/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1060,7 +1060,7 @@
INFOPLIST_FILE = SolianNotificationService/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianNotificationService;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@ -11,6 +11,21 @@ import UIKit
) -> Bool {
UNUserNotificationCenter.current().delegate = notifyDelegate
let replyableMessageCategory = UNNotificationCategory(
identifier: "REPLYABLE_MESSAGE",
actions: [
UNTextInputNotificationAction(
identifier: "reply_action",
title: "Reply",
options: []
),
],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory])
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

View File

@ -10,40 +10,51 @@ import Alamofire
class NotifyDelegate: UIResponder, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
if let textResponse = response as? UNTextInputNotificationResponse {
let content = response.notification.request.content
guard let metadata = content.userInfo["meta"] as? [AnyHashable: Any] else {
return
}
var token: String? = UserDefaults.standard.getFlutterToken()
if token == nil {
return
}
let serverUrl = UserDefaults.standard.getServerUrl()
let url = "\(serverUrl)/chat/\(metadata["room_id"] ?? "")/messages"
let parameters: [String: Any?] = [
"content": textResponse.userText,
"replied_message_id": metadata["message_id"]
]
AF.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: HTTPHeaders(
[HTTPHeader(name: "Authorization", value: "AtField \(token!)")]
))
.validate()
.responseString { response in
switch response.result {
case .success(_):
break
case .failure(let error):
print("Failed to send chat reply message: \(error)")
break
}
}
guard let textResponse = response as? UNTextInputNotificationResponse else {
completionHandler()
return
}
let content = response.notification.request.content
// Only handle replies for new messages
guard let notificationType = content.userInfo["type"] as? String, notificationType == "messages.new" else {
completionHandler()
return
}
guard let metadata = content.userInfo["meta"] as? [AnyHashable: Any] else {
completionHandler()
return
}
completionHandler()
guard let token = UserDefaults.standard.getFlutterToken() else {
completionHandler()
return
}
let serverUrl = UserDefaults.standard.getServerUrl()
let url = "\(serverUrl)/chat/\(metadata["room_id"] ?? "")/messages"
let parameters: [String: Any?] = [
"content": textResponse.userText,
"replied_message_id": metadata["message_id"]
]
AF.request(url, method: .post, parameters: parameters, encoding: JSONEncoding.default, headers: HTTPHeaders(
[HTTPHeader(name: "Authorization", value: "AtField \(token)")]
))
.validate()
.responseString { response in
switch response.result {
case .success(_):
break
case .failure(let error):
print("Failed to send chat reply message: \(error)")
break
}
// Call completion handler after network request is finished
completionHandler()
}
}
}

View File

@ -60,21 +60,7 @@ class NotificationService: UNNotificationServiceExtension {
let pfpIdentifier = meta["pfp"] as? String
let replyableMessageCategory = UNNotificationCategory(
identifier: content.categoryIdentifier,
actions: [
UNTextInputNotificationAction(
identifier: "reply_action",
title: "Reply",
options: []
),
],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory])
content.categoryIdentifier = replyableMessageCategory.identifier
content.categoryIdentifier = "REPLYABLE_MESSAGE"
let metaCopy = meta as? [String: Any] ?? [:]
let pfpUrl = pfpIdentifier != nil ? getAttachmentUrl(for: pfpIdentifier!) : nil

View File

@ -71,25 +71,32 @@ class MessageRepository {
bool synced = false,
}) async {
try {
// For initial load, fetch latest messages in the background to sync.
if (offset == 0 && !synced) {
// Not awaiting this is intentional, for a quicker UI response.
// The UI should rely on a stream from the database to get updates.
_fetchAndCacheMessages(room.id, offset: 0, take: take).catchError((_) {
// Best effort, errors will be handled by later fetches.
return <LocalChatMessage>[];
});
}
final localMessages = await _getCachedMessages(
room.id,
offset: offset,
take: take,
);
// If it already synced with the remote, skip this
if (offset == 0 && !synced) {
// Fetch latest messages
_fetchAndCacheMessages(room.id, offset: offset, take: take);
if (localMessages.isNotEmpty) {
return localMessages;
}
// If local cache has messages, return them. This is the common case for scrolling up.
if (localMessages.isNotEmpty) {
return localMessages;
}
// If local cache is empty, we've probably reached the end of cached history.
// Fetch from remote. This will also be hit on first load if cache is empty.
return await _fetchAndCacheMessages(room.id, offset: offset, take: take);
} catch (e) {
// If API fails but we have local messages, return them
// Final fallback to cache in case of network errors during fetch.
final localMessages = await _getCachedMessages(
room.id,
offset: offset,
@ -117,24 +124,26 @@ class MessageRepository {
final dbLocalMessages =
dbMessages.map(_database.companionToMessage).toList();
// Combine with pending messages
final pendingForRoom =
pendingMessages.values.where((msg) => msg.roomId == roomId).toList();
// Combine with pending messages for the first page
if (offset == 0) {
final pendingForRoom =
pendingMessages.values.where((msg) => msg.roomId == roomId).toList();
// Sort by timestamp descending (newest first)
final allMessages = [...pendingForRoom, ...dbLocalMessages];
allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
final allMessages = [...pendingForRoom, ...dbLocalMessages];
allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
// Apply pagination
if (offset >= allMessages.length) {
return [];
// Remove duplicates by ID, preserving the order
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in allMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
return uniqueMessages;
}
final end =
(offset + take) > allMessages.length
? allMessages.length
: (offset + take);
return allMessages.sublist(offset, end);
return dbLocalMessages;
}
Future<List<LocalChatMessage>> _fetchAndCacheMessages(

View File

@ -29,6 +29,12 @@ import 'package:image_picker_platform_interface/image_picker_platform_interface.
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:url_launcher/url_launcher_string.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
log('Handling a background message: ${message.messageId}');
}
void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
@ -43,6 +49,7 @@ void main() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
log("[SplashScreen] Firebase is ready!");
} catch (err) {
showErrorAlert(err);
@ -151,17 +158,30 @@ class IslandApp extends HookConsumerWidget {
}
useEffect(() {
Future(() async {
RemoteMessage? initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
handleMessage(initialMessage);
// When the app is opened from a terminated state.
FirebaseMessaging.instance.getInitialMessage().then((message) {
if (message != null) {
handleMessage(message);
}
FirebaseMessaging.onMessageOpenedApp.listen(handleMessage);
});
return null;
// When the app is in the background and opened.
final onMessageOpenedAppSubscription = FirebaseMessaging
.onMessageOpenedApp
.listen(handleMessage);
// When the app is in the foreground.
final onMessageSubscription = FirebaseMessaging.onMessage.listen((
message,
) {
log('Foreground message received: ${message.messageId}');
handleMessage(message);
});
return () {
onMessageOpenedAppSubscription.cancel();
onMessageSubscription.cancel();
};
}, []);
useEffect(() {
@ -185,7 +205,7 @@ class IslandApp extends HookConsumerWidget {
}, []);
final router = ref.watch(routerProvider);
return MaterialApp.router(
theme: theme?.light,
darkTheme: theme?.dark,
@ -204,9 +224,8 @@ class IslandApp extends HookConsumerWidget {
initialEntries: [
OverlayEntry(
builder:
(_) => WindowScaffold(
child: child ?? const SizedBox.shrink(),
),
(_) =>
WindowScaffold(child: child ?? const SizedBox.shrink()),
),
],
);

View File

@ -13,7 +13,8 @@ sealed class SnChatRoom with _$SnChatRoom {
required String? name,
required String? description,
required int type,
required bool isPublic,
@Default(false) bool isPublic,
@Default(false) bool isCommunity,
required SnCloudFile? picture,
required SnCloudFile? background,
required String? realmId,

View File

@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnChatRoom {
String get id; String? get name; String? get description; int get type; bool get isPublic; SnCloudFile? get picture; SnCloudFile? get background; String? get realmId; SnRealm? get realm; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; List<SnChatMember>? get members;
String get id; String? get name; String? get description; int get type; bool get isPublic; bool get isCommunity; SnCloudFile? get picture; SnCloudFile? get background; String? get realmId; SnRealm? get realm; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; List<SnChatMember>? get members;
/// Create a copy of SnChatRoom
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -29,16 +29,16 @@ $SnChatRoomCopyWith<SnChatRoom> get copyWith => _$SnChatRoomCopyWithImpl<SnChatR
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other.members, members));
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.isCommunity, isCommunity) || other.isCommunity == isCommunity)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other.members, members));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(members));
int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,isCommunity,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(members));
@override
String toString() {
return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)';
return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, isCommunity: $isCommunity, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)';
}
@ -49,7 +49,7 @@ abstract mixin class $SnChatRoomCopyWith<$Res> {
factory $SnChatRoomCopyWith(SnChatRoom value, $Res Function(SnChatRoom) _then) = _$SnChatRoomCopyWithImpl;
@useResult
$Res call({
String id, String? name, String? description, int type, bool isPublic, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members
String id, String? name, String? description, int type, bool isPublic, bool isCommunity, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members
});
@ -66,13 +66,14 @@ class _$SnChatRoomCopyWithImpl<$Res>
/// Create a copy of SnChatRoom
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? isCommunity = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable
as bool,isCommunity: null == isCommunity ? _self.isCommunity : isCommunity // ignore: cast_nullable_to_non_nullable
as bool,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable
@ -128,14 +129,15 @@ $SnRealmCopyWith<$Res>? get realm {
@JsonSerializable()
class _SnChatRoom implements SnChatRoom {
const _SnChatRoom({required this.id, required this.name, required this.description, required this.type, required this.isPublic, required this.picture, required this.background, required this.realmId, required this.realm, required this.createdAt, required this.updatedAt, required this.deletedAt, required final List<SnChatMember>? members}): _members = members;
const _SnChatRoom({required this.id, required this.name, required this.description, required this.type, this.isPublic = false, this.isCommunity = false, required this.picture, required this.background, required this.realmId, required this.realm, required this.createdAt, required this.updatedAt, required this.deletedAt, required final List<SnChatMember>? members}): _members = members;
factory _SnChatRoom.fromJson(Map<String, dynamic> json) => _$SnChatRoomFromJson(json);
@override final String id;
@override final String? name;
@override final String? description;
@override final int type;
@override final bool isPublic;
@override@JsonKey() final bool isPublic;
@override@JsonKey() final bool isCommunity;
@override final SnCloudFile? picture;
@override final SnCloudFile? background;
@override final String? realmId;
@ -166,16 +168,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other._members, _members));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatRoom&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.type, type) || other.type == type)&&(identical(other.isPublic, isPublic) || other.isPublic == isPublic)&&(identical(other.isCommunity, isCommunity) || other.isCommunity == isCommunity)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.background, background) || other.background == background)&&(identical(other.realmId, realmId) || other.realmId == realmId)&&(identical(other.realm, realm) || other.realm == realm)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&const DeepCollectionEquality().equals(other._members, _members));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(_members));
int get hashCode => Object.hash(runtimeType,id,name,description,type,isPublic,isCommunity,picture,background,realmId,realm,createdAt,updatedAt,deletedAt,const DeepCollectionEquality().hash(_members));
@override
String toString() {
return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)';
return 'SnChatRoom(id: $id, name: $name, description: $description, type: $type, isPublic: $isPublic, isCommunity: $isCommunity, picture: $picture, background: $background, realmId: $realmId, realm: $realm, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, members: $members)';
}
@ -186,7 +188,7 @@ abstract mixin class _$SnChatRoomCopyWith<$Res> implements $SnChatRoomCopyWith<$
factory _$SnChatRoomCopyWith(_SnChatRoom value, $Res Function(_SnChatRoom) _then) = __$SnChatRoomCopyWithImpl;
@override @useResult
$Res call({
String id, String? name, String? description, int type, bool isPublic, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members
String id, String? name, String? description, int type, bool isPublic, bool isCommunity, SnCloudFile? picture, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members
});
@ -203,13 +205,14 @@ class __$SnChatRoomCopyWithImpl<$Res>
/// Create a copy of SnChatRoom
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? isCommunity = null,Object? picture = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) {
return _then(_SnChatRoom(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable
as bool,isCommunity: null == isCommunity ? _self.isCommunity : isCommunity // ignore: cast_nullable_to_non_nullable
as bool,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,realmId: freezed == realmId ? _self.realmId : realmId // ignore: cast_nullable_to_non_nullable

View File

@ -11,7 +11,8 @@ _SnChatRoom _$SnChatRoomFromJson(Map<String, dynamic> json) => _SnChatRoom(
name: json['name'] as String?,
description: json['description'] as String?,
type: (json['type'] as num).toInt(),
isPublic: json['is_public'] as bool,
isPublic: json['is_public'] as bool? ?? false,
isCommunity: json['is_community'] as bool? ?? false,
picture:
json['picture'] == null
? null
@ -44,6 +45,7 @@ Map<String, dynamic> _$SnChatRoomToJson(_SnChatRoom instance) =>
'description': instance.description,
'type': instance.type,
'is_public': instance.isPublic,
'is_community': instance.isCommunity,
'picture': instance.picture?.toJson(),
'background': instance.background?.toJson(),
'realm_id': instance.realmId,

View File

@ -10,8 +10,8 @@ sealed class SnRealm with _$SnRealm {
const factory SnRealm({
required String id,
required String slug,
required String name,
required String description,
@Default('') String name,
@Default('') String description,
required String? verifiedAs,
required DateTime? verifiedAt,
required bool isCommunity,

View File

@ -117,13 +117,13 @@ $SnCloudFileCopyWith<$Res>? get background {
@JsonSerializable()
class _SnRealm implements SnRealm {
const _SnRealm({required this.id, required this.slug, required this.name, required this.description, required this.verifiedAs, required this.verifiedAt, required this.isCommunity, required this.isPublic, required this.picture, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt});
const _SnRealm({required this.id, required this.slug, this.name = '', this.description = '', required this.verifiedAs, required this.verifiedAt, required this.isCommunity, required this.isPublic, required this.picture, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnRealm.fromJson(Map<String, dynamic> json) => _$SnRealmFromJson(json);
@override final String id;
@override final String slug;
@override final String name;
@override final String description;
@override@JsonKey() final String name;
@override@JsonKey() final String description;
@override final String? verifiedAs;
@override final DateTime? verifiedAt;
@override final bool isCommunity;

View File

@ -9,8 +9,8 @@ part of 'realm.dart';
_SnRealm _$SnRealmFromJson(Map<String, dynamic> json) => _SnRealm(
id: json['id'] as String,
slug: json['slug'] as String,
name: json['name'] as String,
description: json['description'] as String,
name: json['name'] as String? ?? '',
description: json['description'] as String? ?? '',
verifiedAs: json['verified_as'] as String?,
verifiedAt:
json['verified_at'] == null

View File

@ -32,7 +32,6 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
state = const AsyncValue.data(null);
final prefs = _ref.read(sharedPreferencesProvider);
await prefs.remove(kTokenPairStoreKey);
_ref.invalidate(userInfoProvider);
_ref.invalidate(tokenProvider);
}
}

View File

@ -31,6 +31,7 @@ import 'package:island/screens/settings.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/screens/realm/detail.dart';
import 'package:island/screens/account/event_calendar.dart';
import 'package:island/screens/discovery/realms.dart';
// Shell route keys for nested navigation
final rootNavigatorKey = GlobalKey<NavigatorState>();
@ -52,7 +53,10 @@ final routerProvider = Provider<GoRouter>((ref) {
// Standalone routes without bottom navigation
GoRoute(
path: '/posts/compose',
builder: (context, state) => const PostComposeScreen(),
builder:
(context, state) => PostComposeScreen(
initialState: state.extra as PostComposeInitialState?,
),
),
GoRoute(
path: '/posts/:id/edit',
@ -105,10 +109,7 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) {
final name = state.pathParameters['name']!;
final packId = state.pathParameters['packId']!;
return EditStickerPacksScreen(
pubName: name,
packId: packId,
);
return EditStickerPacksScreen(pubName: name, packId: packId);
},
),
GoRoute(
@ -190,6 +191,10 @@ final routerProvider = Provider<GoRouter>((ref) {
return PublisherProfileScreen(name: name);
},
),
GoRoute(
path: 'discovery/realms',
builder: (context, state) => const DiscoveryRealmsScreen(),
),
],
),
@ -198,6 +203,10 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/chat',
builder: (context, state) => const ChatListScreen(),
routes: [
GoRoute(
path: 'new',
builder: (context, state) => const NewChatScreen(),
),
GoRoute(
path: ':id',
builder: (context, state) {
@ -205,10 +214,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ChatRoomScreen(id: id);
},
),
GoRoute(
path: 'new',
builder: (context, state) => const NewChatScreen(),
),
GoRoute(
path: ':id/edit',
builder: (context, state) {
@ -227,9 +232,9 @@ final routerProvider = Provider<GoRouter>((ref) {
),
// Realms tab
GoRoute(
path: '/realms',
builder: (context, state) => const RealmListScreen(),
GoRoute(
path: '/realms',
builder: (context, state) => const RealmListScreen(),
routes: [
GoRoute(
path: 'new',

View File

@ -200,7 +200,7 @@ class AccountScreen extends HookConsumerWidget {
],
),
onTap: () {
context.push('/notification');
context.push('/account/notifications');
},
),
ListTile(

View File

@ -215,6 +215,7 @@ class RelationshipScreen extends HookConsumerWidget {
Future<void> addFriend() async {
final result = await showModalBottomSheet(
context: context,
useRootNavigator: true,
builder: (context) => AccountPickerSheet(),
);
if (result == null) return;

View File

@ -186,7 +186,7 @@ class ChatShellScreen extends HookConsumerWidget {
child: Row(
children: [
Flexible(flex: 2, child: ChatListScreen(isAside: true)),
VerticalDivider(width: 1),
const VerticalDivider(width: 1),
Flexible(flex: 4, child: child),
],
),
@ -227,7 +227,8 @@ class ChatListScreen extends HookConsumerWidget {
Future<void> createDirectMessage() async {
final result = await showModalBottomSheet(
context: context,
builder: (context) => AccountPickerSheet(),
useRootNavigator: true,
builder: (context) => const AccountPickerSheet(),
);
if (result == null) return;
final client = ref.read(apiClientProvider);
@ -242,7 +243,7 @@ class ChatListScreen extends HookConsumerWidget {
return AppScaffold(
extendBody: false, // Prevent conflicts with tabs navigation
appBar: AppBar(
title: Text('chat').tr(),
title: const Text('chat').tr(),
bottom: TabBar(
controller: tabController,
tabs: [
@ -296,7 +297,7 @@ class ChatListScreen extends HookConsumerWidget {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => _ChatInvitesSheet(),
builder: (context) => const _ChatInvitesSheet(),
);
},
),
@ -307,13 +308,14 @@ class ChatListScreen extends HookConsumerWidget {
onPressed: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
builder:
(context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
title: Text('createChatRoom').tr(),
title: const Text('createChatRoom').tr(),
leading: const Icon(Symbols.add),
onTap: () {
Navigator.pop(context);
@ -325,7 +327,7 @@ class ChatListScreen extends HookConsumerWidget {
},
),
ListTile(
title: Text('createDirectMessage').tr(),
title: const Text('createDirectMessage').tr(),
leading: const Icon(Symbols.person),
onTap: () {
Navigator.pop(context);
@ -432,17 +434,31 @@ class ChatListScreen extends HookConsumerWidget {
@riverpod
Future<SnChatRoom?> chatroom(Ref ref, String? identifier) async {
if (identifier == null) return null;
final client = ref.watch(apiClientProvider);
final resp = await client.get('/chat/$identifier');
return SnChatRoom.fromJson(resp.data);
try {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/chat/$identifier');
return SnChatRoom.fromJson(resp.data);
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // Chat room not found
}
rethrow; // Rethrow other errors
}
}
@riverpod
Future<SnChatMember?> chatroomIdentity(Ref ref, String? identifier) async {
if (identifier == null) return null;
final client = ref.watch(apiClientProvider);
final resp = await client.get('/chat/$identifier/members/me');
return SnChatMember.fromJson(resp.data);
try {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/chat/$identifier/members/me');
return SnChatMember.fromJson(resp.data);
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // Chat member not found
}
rethrow; // Rethrow other errors
}
}
class NewChatScreen extends StatelessWidget {
@ -450,7 +466,7 @@ class NewChatScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return EditChatScreen();
return const EditChatScreen();
}
}
@ -468,6 +484,8 @@ class EditChatScreen extends HookConsumerWidget {
final descriptionController = useTextEditingController();
final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final chat = ref.watch(chatroomProvider(id));
@ -480,12 +498,14 @@ class EditChatScreen extends HookConsumerWidget {
descriptionController.text = chat.value!.description ?? '';
picture.value = chat.value!.picture;
background.value = chat.value!.background;
isPublic.value = chat.value!.isPublic;
isCommunity.value = chat.value!.isCommunity;
currentRealm.value = joinedRealms.value?.firstWhereOrNull(
(realm) => realm.id == chat.value!.realmId,
);
}
return;
}, [chat]);
}, [chat, joinedRealms]);
void setPicture(String position) async {
showLoadingModal(context);
@ -503,9 +523,9 @@ class EditChatScreen extends HookConsumerWidget {
image: result,
allowedAspectRatios: [
if (position == 'background')
CropAspectRatio(height: 7, width: 16)
const CropAspectRatio(height: 7, width: 16)
else
CropAspectRatio(height: 1, width: 1),
const CropAspectRatio(height: 1, width: 1),
],
);
if (result == null) {
@ -562,6 +582,8 @@ class EditChatScreen extends HookConsumerWidget {
'background_id': background.value?.id,
'picture_id': picture.value?.id,
'realm_id': currentRealm.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
},
options: Options(method: id == null ? 'POST' : 'PATCH'),
);
@ -654,6 +676,19 @@ class EditChatScreen extends HookConsumerWidget {
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
CheckboxListTile(
title: const Text('isPublic').tr(),
subtitle: const Text('isPublicHint').tr(),
value: isPublic.value,
onChanged: (value) => isPublic.value = value ?? false,
),
CheckboxListTile(
title: const Text('isCommunity').tr(),
subtitle: const Text('isCommunityHint').tr(),
value: isCommunity.value,
onChanged: (value) => isCommunity.value = value ?? false,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
@ -754,7 +789,7 @@ class _ChatInvitesSheet extends HookConsumerWidget {
),
if (invite.chatRoom!.type == 1)
Badge(
label: Text('directMessage').tr(),
label: const Text('directMessage').tr(),
backgroundColor:
Theme.of(context).colorScheme.primary,
textColor:

View File

@ -25,7 +25,7 @@ final chatroomsJoinedProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ChatroomsJoinedRef = AutoDisposeFutureProviderRef<List<SnChatRoom>>;
String _$chatroomHash() => r'dce3c0fc407f178bb7c306a08b9fa545795a9205';
String _$chatroomHash() => r'8dac7aaac50932e6dd213039102d43c1cf5f1d4e';
/// Copied from Dart SDK
class _SystemHash {
@ -164,7 +164,7 @@ class _ChatroomProviderElement
String? get identifier => (origin as ChatroomProvider).identifier;
}
String _$chatroomIdentityHash() => r'4c349ea4265df7b0498cf26c82dbaabe3d868727';
String _$chatroomIdentityHash() => r'ad6ad09b6fc4cf7c4abe146ea97f8e364a3d4fd0';
/// See also [chatroomIdentity].
@ProviderFor(chatroomIdentity)

View File

@ -295,6 +295,68 @@ class ChatRoomScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final chatRoom = ref.watch(chatroomProvider(id));
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
if (chatIdentity.isLoading || chatRoom.isLoading) {
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: CircularProgressIndicator().center(),
);
} else if (chatIdentity.value == null) {
// Identity was not found, user was not joined
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: Center(
child:
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
chatRoom.value?.isCommunity == true
? Symbols.person_add
: Symbols.person_remove,
size: 36,
fill: 1,
).padding(bottom: 4),
Text('chatNotJoined').tr(),
if (chatRoom.value?.isCommunity != true)
Text(
'chatUnableJoin',
textAlign: TextAlign.center,
).tr().bold()
else
FilledButton.tonalIcon(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.read(apiClientProvider);
if (chatRoom.value == null) {
hideLoadingModal(context);
return;
}
await apiClient.post(
'/chat/${chatRoom.value!.id}/members/me',
);
ref.invalidate(chatroomIdentityProvider(id));
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
label: Text('chatJoin').tr(),
icon: const Icon(Icons.add),
).padding(top: 8),
],
),
).center(),
),
);
}
final messages = ref.watch(messagesNotifierProvider(id));
final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier);
final ws = ref.watch(websocketProvider);
@ -429,6 +491,28 @@ class ChatRoomScreen extends HookConsumerWidget {
return () => subscription.cancel();
}, [ws, chatRoom]);
useEffect(() {
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': id},
),
),
);
return () {
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': id},
),
),
);
};
}, [id]);
Future<void> pickPhotoMedia() async {
final result = await ref
.watch(imagePickerProvider)
@ -603,7 +687,7 @@ class ChatRoomScreen extends HookConsumerWidget {
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
context.push('/chat/id/detail');
context.push('/chat/$id/detail');
},
),
const Gap(8),

View File

@ -584,8 +584,8 @@ class _ChatMemberListSheet extends HookConsumerWidget {
Future<void> invitePerson() async {
final result = await showModalBottomSheet(
isScrollControlled: true,
context: context,
useRootNavigator: true,
builder: (context) => const AccountPickerSheet(),
);
if (result == null) return;

View 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;
}
});
},
),
),
),
],
),
);
}
}

View File

@ -1,22 +1,25 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/activity.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/tour/tour.dart';
import 'package:island/screens/tabs.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/realm/realm_card.dart';
import 'package:island/widgets/publisher/publisher_card.dart';
import 'package:styled_widget/styled_widget.dart';
part 'explore.g.dart';
@ -83,65 +86,64 @@ class ExploreScreen extends HookConsumerWidget {
activityListNotifierProvider(currentFilter.value).notifier,
);
return TourTriggerWidget(
child: AppScaffold(
extendBody: false, // Prevent conflicts with tabs navigation
appBar: AppBar(
toolbarHeight: 0,
bottom: TabBar(
controller: tabController,
tabs: [
Tab(
child: Text(
'explore'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'exploreFilterSubscriptions'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'exploreFilterFriends'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
],
),
),
floatingActionButton: FloatingActionButton(
heroTag: Key("explore-page-fab"),
onPressed: () {
context.push('/posts/compose').then((value) {
if (value != null) {
activitiesNotifier.forceRefresh();
}
});
},
child: const Icon(Symbols.edit),
),
floatingActionButtonLocation: TabbedFabLocation(context),
body: TabBarView(
return AppScaffold(
extendBody: false, // Prevent conflicts with tabs navigation
appBar: AppBar(
toolbarHeight: 0,
bottom: TabBar(
controller: tabController,
children: [
_buildActivityList(ref, null),
_buildActivityList(ref, 'subscriptions'),
_buildActivityList(ref, 'friends'),
tabs: [
Tab(
child: Text(
'explore'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'exploreFilterSubscriptions'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'exploreFilterFriends'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
],
),
),
floatingActionButton: FloatingActionButton(
heroTag: Key("explore-page-fab"),
onPressed: () {
context.push('/posts/compose').then((value) {
if (value != null) {
activitiesNotifier.forceRefresh();
}
});
},
child: const Icon(Symbols.edit),
),
floatingActionButtonLocation: TabbedFabLocation(context),
body: TabBarView(
controller: tabController,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildActivityList(ref, null),
_buildActivityList(ref, 'subscriptions'),
_buildActivityList(ref, 'friends'),
],
),
);
}
@ -171,6 +173,64 @@ class ExploreScreen extends HookConsumerWidget {
}
}
class _DiscoveryActivityItem extends StatelessWidget {
final Map<String, dynamic> data;
const _DiscoveryActivityItem({required this.data});
@override
Widget build(BuildContext context) {
final items = data['items'] as List;
final type = items.firstOrNull?['type'] ?? 'unknown';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.explore, size: 19),
const Gap(8),
Text(
(switch (type) {
'realm' => 'discoverRealms',
'publisher' => 'discoverPublishers',
_ => 'unknown',
}).tr(),
style: Theme.of(context).textTheme.titleMedium,
).padding(top: 1),
],
).padding(horizontal: 20, top: 8, bottom: 4),
SizedBox(
height: 180,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: items.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final item = items[index];
switch (type) {
case 'realm':
return RealmCard(
realm: SnRealm.fromJson(item['data']),
maxWidth: 280,
);
case 'publisher':
return PublisherCard(
publisher: SnPublisher.fromJson(item['data']),
maxWidth: 280,
);
default:
return Placeholder();
}
},
),
).padding(bottom: 4),
],
);
}
}
class _ActivityListView extends HookConsumerWidget {
final CursorPagingData<SnActivity> data;
final int widgetCount;
@ -214,10 +274,14 @@ class _ActivityListView extends HookConsumerWidget {
itemWidget = PostItem(
backgroundColor:
isWideScreen(context) ? Colors.transparent : null,
item: SnPost.fromJson(item.data),
item: SnPost.fromJson(item.data!),
padding:
isReply
? EdgeInsets.only(left: 16, right: 16, bottom: 16)
? const EdgeInsets.only(
left: 16,
right: 16,
bottom: 16,
)
: null,
onRefresh: (_) {
activitiesNotifier.forceRefresh();
@ -245,6 +309,9 @@ class _ActivityListView extends HookConsumerWidget {
);
}
break;
case 'discovery':
itemWidget = _DiscoveryActivityItem(data: item.data!);
break;
default:
itemWidget = const Placeholder();
}
@ -274,6 +341,7 @@ class ActivityListNotifier extends _$ActivityListNotifier
if (cursor != null) 'cursor': cursor,
'take': take,
if (filter != null) 'filter': filter,
if (kDebugMode) 'debugInclude': 'realms,publishers',
};
final response = await client.get(

View File

@ -7,7 +7,7 @@ part of 'explore.dart';
// **************************************************************************
String _$activityListNotifierHash() =>
r'14ec2f211c86e1e64a9a34b142d0e8f78ff6361a';
r'57e9dcec944a9f88f8508b69fc91342592f5b349';
/// Copied from Dart SDK
class _SystemHash {

View File

@ -54,25 +54,26 @@ Future<SnSubscriptionStatus> publisherSubscriptionStatus(
@riverpod
Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async {
final publisher = await ref.watch(publisherProvider(pubName).future);
if (publisher.background == null) return null;
final palette = await PaletteGenerator.fromImageProvider(
CloudImageWidget.provider(
fileId: publisher.background!.id,
serverUrl: ref.watch(serverUrlProvider),
),
);
final dominantColor = palette.dominantColor?.color;
if (dominantColor == null) return null;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
try {
final publisher = await ref.watch(publisherProvider(pubName).future);
if (publisher.background == null) return null;
final palette = await PaletteGenerator.fromImageProvider(
CloudImageWidget.provider(
fileId: publisher.background!.id,
serverUrl: ref.watch(serverUrlProvider),
),
);
final dominantColor = palette.dominantColor?.color;
if (dominantColor == null) return null;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
} catch (_) {
return null;
}
}
class PublisherProfileScreen extends HookConsumerWidget {
final String name;
const PublisherProfileScreen({
super.key,
required this.name,
});
const PublisherProfileScreen({super.key, required this.name});
@override
Widget build(BuildContext context, WidgetRef ref) {

View File

@ -1,12 +1,17 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:flutter/material.dart';
import 'package:island/models/chat.dart';
import 'package:island/services/color.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/config.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart';
@ -19,11 +24,40 @@ import 'package:styled_widget/styled_widget.dart';
part 'detail.g.dart';
@riverpod
Future<Color?> realmAppbarForegroundColor(Ref ref, String realmSlug) async {
final realm = await ref.watch(realmProvider(realmSlug).future);
if (realm?.background == null) return null;
final palette = await PaletteGenerator.fromImageProvider(
CloudImageWidget.provider(
fileId: realm!.background!.id,
serverUrl: ref.watch(serverUrlProvider),
),
);
final dominantColor = palette.dominantColor?.color;
if (dominantColor == null) return null;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
}
@riverpod
Future<SnRealmMember?> realmIdentity(Ref ref, String realmSlug) async {
try {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/realms/$realmSlug/members/me');
return SnRealmMember.fromJson(response.data);
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // No identity found, user is not a member
}
rethrow;
}
}
@riverpod
Future<List<SnChatRoom>> realmChatRooms(Ref ref, String realmSlug) async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get('/realms/$realmSlug/members/me');
return SnRealmMember.fromJson(response.data);
final response = await apiClient.get('/realms/$realmSlug/chat');
return (response.data as List).map((e) => SnChatRoom.fromJson(e)).toList();
}
class RealmDetailScreen extends HookConsumerWidget {
@ -34,9 +68,10 @@ class RealmDetailScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final realmState = ref.watch(realmProvider(slug));
final appbarColor = ref.watch(realmAppbarForegroundColorProvider(slug));
const iconShadow = Shadow(
color: Colors.black54,
final iconShadow = Shadow(
color: appbarColor.value?.invert ?? Colors.black54,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
);
@ -51,7 +86,11 @@ class RealmDetailScreen extends HookConsumerWidget {
SliverAppBar(
expandedHeight: 180,
pinned: true,
leading: PageBackButton(shadows: [iconShadow]),
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [iconShadow],
),
flexibleSpace: FlexibleSpaceBar(
background:
realm!.background?.id != null
@ -63,14 +102,16 @@ class RealmDetailScreen extends HookConsumerWidget {
title: Text(
realm.name,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.people, shadows: [iconShadow]),
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
@ -86,18 +127,97 @@ class RealmDetailScreen extends HookConsumerWidget {
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
realm.description,
style: const TextStyle(fontSize: 16),
),
],
),
),
child: ref
.watch(realmIdentityProvider(slug))
.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data:
(identity) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ExpansionTile(
title: const Text('description').tr(),
initiallyExpanded: identity == null,
tilePadding: EdgeInsets.symmetric(
horizontal: 20,
),
expandedCrossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
Text(
realm.description,
style: const TextStyle(fontSize: 16),
).padding(
horizontal: 20,
bottom: 16,
top: 8,
),
],
),
if (identity == null && realm.isCommunity)
FilledButton.tonalIcon(
onPressed: () async {
try {
final apiClient = ref.read(
apiClientProvider,
);
await apiClient.post(
'/realms/$slug/members/me',
);
ref.invalidate(
realmIdentityProvider(slug),
);
ref.invalidate(realmsJoinedProvider);
showSnackBar('realmJoinSuccess'.tr());
} catch (err) {
showErrorAlert(err);
}
},
icon: const Icon(Symbols.add),
label: const Text('realmJoin').tr(),
).padding(horizontal: 16, vertical: 16)
else
const SizedBox.shrink(),
],
),
),
),
const SliverToBoxAdapter(child: Divider(height: 1)),
Consumer(
builder: (context, ref, _) {
final chatRooms = ref.watch(realmChatRoomsProvider(slug));
return chatRooms.when(
loading:
() => const SliverToBoxAdapter(
child: Center(child: CircularProgressIndicator()),
),
error:
(error, _) => SliverToBoxAdapter(
child: Center(child: Text('Error: $error')),
),
data: (rooms) {
if (rooms.isEmpty) {
return const SliverToBoxAdapter(
child: SizedBox.shrink(),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate((
context,
index,
) {
return ChatRoomListTile(
room: rooms[index],
onTap: () {
context.push('/chat/${rooms[index].id}');
},
);
}, childCount: rooms.length),
);
},
);
},
),
],
),
@ -114,8 +234,8 @@ class _RealmActionMenu extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final realmIdentityAsync = ref.watch(realmIdentityProvider(realmSlug));
final isModerator = realmIdentityAsync.when(
final realmIdentity = ref.watch(realmIdentityProvider(realmSlug));
final isModerator = realmIdentity.when(
data: (identity) => (identity?.role ?? 0) >= 50,
loading: () => false,
error: (_, _) => false,
@ -141,7 +261,7 @@ class _RealmActionMenu extends HookConsumerWidget {
],
),
),
realmIdentityAsync.when(
realmIdentity.when(
data:
(identity) =>
(identity?.role ?? 0) >= 100

View File

@ -6,7 +6,8 @@ part of 'detail.dart';
// RiverpodGenerator
// **************************************************************************
String _$realmIdentityHash() => r'eac6e829b5b46bcfadbf201ab6f918d78c894b9f';
String _$realmAppbarForegroundColorHash() =>
r'14b5563d861996ea182d0d2db7aa5c2bb3bbaf48';
/// Copied from Dart SDK
class _SystemHash {
@ -29,6 +30,133 @@ class _SystemHash {
}
}
/// See also [realmAppbarForegroundColor].
@ProviderFor(realmAppbarForegroundColor)
const realmAppbarForegroundColorProvider = RealmAppbarForegroundColorFamily();
/// See also [realmAppbarForegroundColor].
class RealmAppbarForegroundColorFamily extends Family<AsyncValue<Color?>> {
/// See also [realmAppbarForegroundColor].
const RealmAppbarForegroundColorFamily();
/// See also [realmAppbarForegroundColor].
RealmAppbarForegroundColorProvider call(String realmSlug) {
return RealmAppbarForegroundColorProvider(realmSlug);
}
@override
RealmAppbarForegroundColorProvider getProviderOverride(
covariant RealmAppbarForegroundColorProvider provider,
) {
return call(provider.realmSlug);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'realmAppbarForegroundColorProvider';
}
/// See also [realmAppbarForegroundColor].
class RealmAppbarForegroundColorProvider
extends AutoDisposeFutureProvider<Color?> {
/// See also [realmAppbarForegroundColor].
RealmAppbarForegroundColorProvider(String realmSlug)
: this._internal(
(ref) => realmAppbarForegroundColor(
ref as RealmAppbarForegroundColorRef,
realmSlug,
),
from: realmAppbarForegroundColorProvider,
name: r'realmAppbarForegroundColorProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$realmAppbarForegroundColorHash,
dependencies: RealmAppbarForegroundColorFamily._dependencies,
allTransitiveDependencies:
RealmAppbarForegroundColorFamily._allTransitiveDependencies,
realmSlug: realmSlug,
);
RealmAppbarForegroundColorProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.realmSlug,
}) : super.internal();
final String realmSlug;
@override
Override overrideWith(
FutureOr<Color?> Function(RealmAppbarForegroundColorRef provider) create,
) {
return ProviderOverride(
origin: this,
override: RealmAppbarForegroundColorProvider._internal(
(ref) => create(ref as RealmAppbarForegroundColorRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
realmSlug: realmSlug,
),
);
}
@override
AutoDisposeFutureProviderElement<Color?> createElement() {
return _RealmAppbarForegroundColorProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is RealmAppbarForegroundColorProvider &&
other.realmSlug == realmSlug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, realmSlug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin RealmAppbarForegroundColorRef on AutoDisposeFutureProviderRef<Color?> {
/// The parameter `realmSlug` of this provider.
String get realmSlug;
}
class _RealmAppbarForegroundColorProviderElement
extends AutoDisposeFutureProviderElement<Color?>
with RealmAppbarForegroundColorRef {
_RealmAppbarForegroundColorProviderElement(super.provider);
@override
String get realmSlug =>
(origin as RealmAppbarForegroundColorProvider).realmSlug;
}
String _$realmIdentityHash() => r'308d43eef8a6145c762d27bdf7e12e27149524db';
/// See also [realmIdentity].
@ProviderFor(realmIdentity)
const realmIdentityProvider = RealmIdentityFamily();
@ -148,6 +276,128 @@ class _RealmIdentityProviderElement
String get realmSlug => (origin as RealmIdentityProvider).realmSlug;
}
String _$realmChatRoomsHash() => r'8207c1e6f0922323967f208efeed027e943039cc';
/// See also [realmChatRooms].
@ProviderFor(realmChatRooms)
const realmChatRoomsProvider = RealmChatRoomsFamily();
/// See also [realmChatRooms].
class RealmChatRoomsFamily extends Family<AsyncValue<List<SnChatRoom>>> {
/// See also [realmChatRooms].
const RealmChatRoomsFamily();
/// See also [realmChatRooms].
RealmChatRoomsProvider call(String realmSlug) {
return RealmChatRoomsProvider(realmSlug);
}
@override
RealmChatRoomsProvider getProviderOverride(
covariant RealmChatRoomsProvider provider,
) {
return call(provider.realmSlug);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'realmChatRoomsProvider';
}
/// See also [realmChatRooms].
class RealmChatRoomsProvider
extends AutoDisposeFutureProvider<List<SnChatRoom>> {
/// See also [realmChatRooms].
RealmChatRoomsProvider(String realmSlug)
: this._internal(
(ref) => realmChatRooms(ref as RealmChatRoomsRef, realmSlug),
from: realmChatRoomsProvider,
name: r'realmChatRoomsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$realmChatRoomsHash,
dependencies: RealmChatRoomsFamily._dependencies,
allTransitiveDependencies:
RealmChatRoomsFamily._allTransitiveDependencies,
realmSlug: realmSlug,
);
RealmChatRoomsProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.realmSlug,
}) : super.internal();
final String realmSlug;
@override
Override overrideWith(
FutureOr<List<SnChatRoom>> Function(RealmChatRoomsRef provider) create,
) {
return ProviderOverride(
origin: this,
override: RealmChatRoomsProvider._internal(
(ref) => create(ref as RealmChatRoomsRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
realmSlug: realmSlug,
),
);
}
@override
AutoDisposeFutureProviderElement<List<SnChatRoom>> createElement() {
return _RealmChatRoomsProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is RealmChatRoomsProvider && other.realmSlug == realmSlug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, realmSlug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin RealmChatRoomsRef on AutoDisposeFutureProviderRef<List<SnChatRoom>> {
/// The parameter `realmSlug` of this provider.
String get realmSlug;
}
class _RealmChatRoomsProviderElement
extends AutoDisposeFutureProviderElement<List<SnChatRoom>>
with RealmChatRoomsRef {
_RealmChatRoomsProviderElement(super.provider);
@override
String get realmSlug => (origin as RealmChatRoomsProvider).realmSlug;
}
String _$realmMemberListNotifierHash() =>
r'b2e3eefc62a597f45df9470b2058fdda62f8853f';

View File

@ -46,6 +46,10 @@ class RealmListScreen extends HookConsumerWidget {
appBar: AppBar(
title: const Text('realms').tr(),
actions: [
IconButton(
icon: const Icon(Symbols.travel_explore),
onPressed: () => context.push('/discovery/realms'),
),
IconButton(
icon: Badge(
label: Text(
@ -66,7 +70,7 @@ class RealmListScreen extends HookConsumerWidget {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => _RealmInviteSheet(),
builder: (_) => const _RealmInviteSheet(),
);
},
),
@ -74,7 +78,7 @@ class RealmListScreen extends HookConsumerWidget {
],
),
floatingActionButton: FloatingActionButton(
heroTag: Key("realms-page-fab"),
heroTag: const Key("realms-page-fab"),
child: const Icon(Symbols.add),
onPressed: () {
context.push('/realms/new').then((value) {
@ -106,7 +110,7 @@ class RealmListScreen extends HookConsumerWidget {
onTap: () {
context.push('/realms/${value[item].slug}');
},
contentPadding: EdgeInsets.only(
contentPadding: const EdgeInsets.only(
left: 16,
right: 14,
top: 8,
@ -158,6 +162,8 @@ class EditRealmScreen extends HookConsumerWidget {
final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final slugController = useTextEditingController();
final nameController = useTextEditingController();
@ -174,6 +180,8 @@ class EditRealmScreen extends HookConsumerWidget {
slugController.text = realm.value!.slug;
nameController.text = realm.value!.name;
descriptionController.text = realm.value!.description;
isPublic.value = realm.value!.isPublic;
isCommunity.value = realm.value!.isCommunity;
}
return null;
}, [realm]);
@ -194,9 +202,9 @@ class EditRealmScreen extends HookConsumerWidget {
image: result,
allowedAspectRatios: [
if (position == 'background')
CropAspectRatio(height: 7, width: 16)
const CropAspectRatio(height: 7, width: 16)
else
CropAspectRatio(height: 1, width: 1),
const CropAspectRatio(height: 1, width: 1),
],
);
if (result == null) {
@ -252,6 +260,8 @@ class EditRealmScreen extends HookConsumerWidget {
'description': descriptionController.text,
'background_id': background.value?.id,
'picture_id': picture.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
},
options: Options(method: slug == null ? 'POST' : 'PATCH'),
);
@ -284,9 +294,9 @@ class EditRealmScreen extends HookConsumerWidget {
child:
background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
@ -314,7 +324,6 @@ class EditRealmScreen extends HookConsumerWidget {
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
TextFormField(
controller: slugController,
@ -325,12 +334,14 @@ class EditRealmScreen extends HookConsumerWidget {
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: InputDecoration(labelText: 'name'.tr()),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(labelText: 'description'.tr()),
@ -339,6 +350,20 @@ class EditRealmScreen extends HookConsumerWidget {
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
CheckboxListTile(
title: const Text('isPublic').tr(),
subtitle: const Text('isPublicHint').tr(),
value: isPublic.value,
onChanged: (value) => isPublic.value = value ?? false,
),
CheckboxListTile(
title: const Text('isCommunity').tr(),
subtitle: const Text('isCommunityHint').tr(),
value: isCommunity.value,
onChanged: (value) => isCommunity.value = value ?? false,
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
@ -410,47 +435,47 @@ class _RealmInviteSheet extends HookConsumerWidget {
(items) =>
items.isEmpty
? Center(
child:
Text(
'invitesEmpty',
textAlign: TextAlign.center,
).tr(),
)
child:
Text(
'invitesEmpty',
textAlign: TextAlign.center,
).tr(),
)
: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final invite = items[index];
return ListTile(
leading: ProfilePictureWidget(
fileId: invite.realm!.picture?.id,
fallbackIcon: Symbols.group,
),
title: Text(invite.realm!.name),
subtitle:
Text(
invite.role >= 100
? 'permissionOwner'
: invite.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
),
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final invite = items[index];
return ListTile(
leading: ProfilePictureWidget(
fileId: invite.realm!.picture?.id,
fallbackIcon: Symbols.group,
),
title: Text(invite.realm!.name),
subtitle:
Text(
invite.role >= 100
? 'permissionOwner'
: invite.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
@ -460,4 +485,4 @@ class _RealmInviteSheet extends HookConsumerWidget {
),
);
}
}
}

View File

@ -32,7 +32,9 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
var uri = notification.meta['action_uri'] as String;
if (uri.startsWith('/')) {
// In-app routes
rootNavigatorKey.currentContext?.push(notification.meta['action_uri']);
rootNavigatorKey.currentContext?.push(
notification.meta['action_uri'],
);
} else {
// External URLs
launchUrlString(uri);
@ -46,8 +48,14 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
padding: EdgeInsets.only(
left: 16,
right: 16,
// ignore: use_build_context_synchronously
top: MediaQuery.of(context).padding.top + 24,
top:
(!kIsWeb &&
(Platform.isMacOS ||
Platform.isWindows ||
Platform.isLinux))
? 24
// ignore: use_build_context_synchronously
: MediaQuery.of(context).padding.top + 8,
bottom: 16,
),
);

View File

@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/sharing_intent.dart';
import 'package:island/widgets/tour/tour.dart';
class AppWrapper extends HookConsumerWidget {
final Widget child;
@ -24,6 +25,6 @@ class AppWrapper extends HookConsumerWidget {
};
}, const []);
return child;
return TourTriggerWidget(child: child);
}
}

View File

@ -233,16 +233,27 @@ class MessageItem extends HookConsumerWidget {
if (remoteMessage.meta['embeds'] != null)
...((remoteMessage.meta['embeds'] as List<dynamic>)
.where((embed) => embed['Type'] == 'link')
.map((embed) => SnEmbedLink.fromJson(embed as Map<String, dynamic>))
.map((link) => LayoutBuilder(
builder: (context, constraints) {
return EmbedLinkWidget(
link: link,
maxWidth: math.min(constraints.maxWidth, 480),
margin: const EdgeInsets.symmetric(vertical: 4),
);
},
))
.map(
(embed) => SnEmbedLink.fromJson(
embed as Map<String, dynamic>,
),
)
.map(
(link) => LayoutBuilder(
builder: (context, constraints) {
return EmbedLinkWidget(
link: link,
maxWidth: math.min(
constraints.maxWidth,
480,
),
margin: const EdgeInsets.symmetric(
vertical: 4,
),
);
},
),
)
.toList()),
if (progress != null && progress!.isNotEmpty)
Column(
@ -482,7 +493,11 @@ class _MessageItemContent extends StatelessWidget {
);
case 'text':
default:
return MarkdownTextContent(content: item.content!, isSelectable: true);
return MarkdownTextContent(
content: item.content!,
isSelectable: true,
linesMargin: EdgeInsets.zero,
);
}
}

View File

@ -186,13 +186,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
Future<void> saveToGallery() async {
try {
// Show loading indicator
final scaffold = ScaffoldMessenger.of(context);
scaffold.showSnackBar(
const SnackBar(
content: Text('Saving image to gallery...'),
duration: Duration(seconds: 1),
),
);
showSnackBar('Saving image to gallery...');
// Get the image URL
final client = ref.watch(apiClientProvider);
@ -209,12 +203,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
await Gal.putImage(filePath, album: 'Solar Network');
// Show success message
scaffold.showSnackBar(
const SnackBar(
content: Text('Image saved to gallery'),
duration: Duration(seconds: 2),
),
);
showSnackBar('Image saved to gallery');
} catch (e) {
showErrorAlert(e);
}

View File

@ -71,7 +71,7 @@ class PostItem extends HookConsumerWidget {
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
context.push('/posts/item.id/edit').then((value) {
context.push('/posts/${item.id}/edit').then((value) {
if (value != null) {
onRefresh?.call();
}
@ -243,7 +243,8 @@ class PostItem extends HookConsumerWidget {
: null,
),
// Render tags and categories if they exist
if (item.tags.isNotEmpty || item.categories.isNotEmpty)
if (item.tags.isNotEmpty ||
item.categories.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -255,9 +256,13 @@ class PostItem extends HookConsumerWidget {
child: Row(
spacing: 4,
children: [
const Icon(Symbols.label, size: 13),
Text(tag.name ?? '#${tag.slug}')
.fontSize(13)
const Icon(
Symbols.label,
size: 13,
),
Text(
tag.name ?? '#${tag.slug}',
).fontSize(13),
],
),
onTap: () {},
@ -272,9 +277,14 @@ class PostItem extends HookConsumerWidget {
child: Row(
spacing: 4,
children: [
const Icon(Symbols.category, size: 13),
Text(category.name ?? '#${category.slug}')
.fontSize(13)
const Icon(
Symbols.category,
size: 13,
),
Text(
category.name ??
'#${category.slug}',
).fontSize(13),
],
),
onTap: () {},
@ -324,7 +334,7 @@ class PostItem extends HookConsumerWidget {
),
onTap: () {
if (isOpenable) {
context.push('/posts/item.id');
context.push('/posts/${item.id}');
}
},
),

View File

@ -45,7 +45,7 @@ class PostItemCreator extends HookConsumerWidget {
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
context.push('/posts/item.id/edit').then((value) {
context.push('/posts/${item.id}/edit').then((value) {
if (value != null) {
onRefresh?.call();
}
@ -80,7 +80,7 @@ class PostItemCreator extends HookConsumerWidget {
image: MenuImage.icon(Symbols.link),
callback: () {
// Copy post link to clipboard
context.push('/posts/item.id');
context.push('/posts/${item.id}');
},
),
],

View 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,
);
}
}

View 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,
);
}
}

View 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),
),
);
}
}

View 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

View 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}'),
);
}
}

View File

@ -13,6 +13,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/services/file.dart';
import 'package:mime/mime.dart';
import 'dart:io';
import 'package:path/path.dart' as path;
@ -149,9 +150,9 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
case ShareContentType.file:
if (widget.content.files != null) {
// Convert XFiles to UniversalFiles
for (final xFile in widget.content.files!) {
final file = File(xFile.path);
final mimeType = xFile.mimeType;
for (final file in widget.content.files!) {
var mimeType = file.mimeType;
mimeType ??= lookupMimeType(file.path);
UniversalFileType fileType;
if (mimeType?.startsWith('image/') == true) {

View File

@ -49,6 +49,8 @@ PODS:
- OrderedSet (~> 6.0.3)
- flutter_platform_alert (0.0.1):
- FlutterMacOS
- flutter_secure_storage_macos (6.1.3):
- FlutterMacOS
- flutter_timezone (0.1.0):
- FlutterMacOS
- flutter_udid (0.0.1):
@ -171,6 +173,7 @@ DEPENDENCIES:
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
- flutter_platform_alert (from `Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos`)
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`)
- flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`)
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
@ -232,6 +235,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
flutter_platform_alert:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos
flutter_secure_storage_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
flutter_timezone:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos
flutter_udid:
@ -295,6 +300,7 @@ SPEC CHECKSUMS:
FirebaseMessaging: 195bbdb73e6ca1dbc76cd46e73f3552c084ef6e4
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284
flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8
flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495
flutter_webrtc: a7eeb54859e672228c28f4b48b1fb61561976ea3

View File

@ -1470,7 +1470,7 @@ packages:
source: hosted
version: "1.16.0"
mime:
dependency: transitive
dependency: "direct main"
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
@ -1785,10 +1785,10 @@ packages:
dependency: transitive
description:
name: record_linux
sha256: "29e7735b05c1944bb6c9b72a36c08d4a1b24117e712d6a9523c003bde12bf484"
sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.1.1"
record_macos:
dependency: transitive
description:
@ -2254,10 +2254,10 @@ packages:
dependency: transitive
description:
name: synchronized
sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6"
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.4.0"
table_calendar:
dependency: "direct main"
description:

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 3.0.0+107
version: 3.0.0+108
environment:
sdk: ^3.7.2
@ -126,6 +126,7 @@ dependencies:
git:
url: https://github.com/lionelmennig/textfield_tags.git
ref: fixes/allow-controller-re-registration
mime: ^2.0.0
dev_dependencies:
flutter_test: