Compare commits

...

27 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
4deff5a920 Post tags 2025-06-27 02:31:21 +08:00
0361f031db Post editor tags 2025-06-27 00:56:07 +08:00
e90b35f19f 🐛 Fix share sheet 2025-06-26 14:45:44 +08:00
f2829b2012 ♻️ Refactor router, moved from auto_router to go_router 2025-06-26 14:13:44 +08:00
825e6b5b6d 🚀 Launch 3.0.0+107 2025-06-26 02:23:36 +08:00
2a3276973c ♻️ Moved to new snackbar 2025-06-26 02:15:19 +08:00
f4e10afa8f 🗑️ Remove snackbar testing code 2025-06-26 02:13:32 +08:00
60c5e584be 💄 Optimized notification snack bar 2025-06-26 02:12:38 +08:00
2b237eaad9 💄 Optimized bottom nav 2025-06-26 01:54:43 +08:00
891a0b999c Update Crowdin configuration file 2025-06-26 00:15:08 +08:00
01da729365 Abuse report 2025-06-25 23:57:55 +08:00
cef313b356 🗑️ Clean up code 2025-06-25 23:03:55 +08:00
8bc8556f06 Share to chat 2025-06-25 23:02:14 +08:00
1a8abe5849 Better link previewing 2025-06-25 22:33:12 +08:00
86258acc6e ♻️ Refactor snackbar 2025-06-25 22:05:37 +08:00
123 changed files with 4897 additions and 3763 deletions

View File

@ -51,14 +51,15 @@ android {
buildTypes { buildTypes {
release { release {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
minifyEnabled = true
shrinkResources = true
} }
} }
} }
dependencies { dependencies {
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
implementation("com.github.bumptech.glide:glide:4.16.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.firebase:firebase-messaging-ktx")
} }
flutter { flutter {

View File

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

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

File diff suppressed because one or more lines are too long

View File

@ -48,6 +48,28 @@
"deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.", "deletePublisherHint": "Are you sure to delete this publisher? This will also deleted all the post and collections under this publisher.",
"somethingWentWrong": "Something went wrong...", "somethingWentWrong": "Something went wrong...",
"deletePost": "Delete Post", "deletePost": "Delete Post",
"safetyReport": "Report",
"safetyReportTitle": "Safety Report",
"safetyReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.",
"safetyReportType": "Report Type",
"safetyReportReason": "Additional Details",
"safetyReportReasonHint": "Please provide more details about the issue...",
"safetyReportSubmit": "Submit Report",
"safetyReportSubmitting": "Submitting...",
"safetyReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.",
"safetyReportError": "Failed to submit report. Please try again.",
"safetyReportReasonRequired": "Please provide details about the issue",
"safetyReportTypeSpam": "Spam or Misleading",
"safetyReportTypeHarassment": "Harassment or Abuse",
"safetyReportTypeHateSpeech": "Hate Speech",
"safetyReportTypeViolence": "Violence or Threats",
"safetyReportTypeAdultContent": "Adult Content",
"safetyReportTypeIntellectualProperty": "Intellectual Property Violation",
"safetyReportTypeOther": "Other",
"safetyReportTypeInappropriate": "Inappropriate Content",
"safetyReportTypeCopyright": "Copyright Violation",
"safetyReportSuccessTitle": "Report Submitted",
"safetyReportErrorTitle": "Error",
"deletePostHint": "Are you sure to delete this post?", "deletePostHint": "Are you sure to delete this post?",
"copyLink": "Copy Link", "copyLink": "Copy Link",
"postCreateAccountTitle": "Thanks for joining!", "postCreateAccountTitle": "Thanks for joining!",
@ -67,30 +89,14 @@
"authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.", "authFactorInAppNotifyDescription": "A one-time code sent via in-app notification.",
"authFactorPin": "Pin Code", "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.", "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", "explore": "Explore",
"exploreFilterSubscriptions": "Subscriptions", "exploreFilterSubscriptions": "Subscriptions",
"exploreFilterFriends": "Friends", "exploreFilterFriends": "Friends",
"discover": "Discover",
"account": "Account", "account": "Account",
"name": "Name", "name": "Name",
"slug": "Slug", "slug": "Slug",
"slugHint": "The slug will be used in the URL to access this resource, it should be unique and URL safe.", "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...", "loading": "Loading...",
"descriptionNone": "No description yet.", "descriptionNone": "No description yet.",
"invites": "Invites", "invites": "Invites",
@ -225,7 +231,6 @@
"uploadingProgress": "Uploading {} of {}", "uploadingProgress": "Uploading {} of {}",
"uploadAll": "Upload All", "uploadAll": "Upload All",
"stickerCopyPlaceholder": "Copy Placeholder", "stickerCopyPlaceholder": "Copy Placeholder",
"realmSelection": "Select a Realm",
"individual": "Individual", "individual": "Individual",
"firstPostBadgeName": "First Post", "firstPostBadgeName": "First Post",
"firstPostBadgeDescription": "Created your first post on Solar Network", "firstPostBadgeDescription": "Created your first post on Solar Network",
@ -281,10 +286,6 @@
"levelingProgressExperience": "{} EXP", "levelingProgressExperience": "{} EXP",
"levelingProgressLevel": "Level {}", "levelingProgressLevel": "Level {}",
"fileUploadingProgress": "Uploading file #{}: {}%", "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", "memberRole": "Member Role",
"memberRoleHint": "Greater number has higher permission.", "memberRoleHint": "Greater number has higher permission.",
"memberRoleEdit": "Edit role for @{}", "memberRoleEdit": "Edit role for @{}",
@ -292,10 +293,6 @@
"openLinkConfirmDescription": "You're going to leave the Solar Network and open the link ({}) in your browser. It is not related to Solar Network. Beware of phishing and scams.", "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...", "brokenLink": "Unable open link {}... It might be broken or missing uri parts...",
"copyToClipboard": "Copy to clipboard", "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", "walletNotFound": "Wallet not found",
"walletCreateHint": "You don't have a wallet yet. Create one to start using the Solar Network eWallet.", "walletCreateHint": "You don't have a wallet yet. Create one to start using the Solar Network eWallet.",
"walletCreate": "Create a Wallet", "walletCreate": "Create a Wallet",
@ -307,12 +304,6 @@
"settingsBackgroundImageClear": "Clear Background Image", "settingsBackgroundImageClear": "Clear Background Image",
"settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image", "settingsBackgroundGenerateColor": "Generate color scheme from Bacground Image",
"messageNone": "No content to display", "messageNone": "No content to display",
"unreadMessages": {
"one": "{} unread message",
"other": "{} unread messages"
},
"chatBreakNone": "None",
"settingsRealmCompactView": "Compact Realm View",
"settingsMixedFeed": "Mixed Feed", "settingsMixedFeed": "Mixed Feed",
"settingsAutoTranslate": "Auto Translate", "settingsAutoTranslate": "Auto Translate",
"settingsHideBottomNav": "Hide Bottom Navigation", "settingsHideBottomNav": "Hide Bottom Navigation",
@ -355,7 +346,6 @@
"postVisibilityUnlisted": "Unlisted", "postVisibilityUnlisted": "Unlisted",
"postVisibilityPrivate": "Private", "postVisibilityPrivate": "Private",
"postTruncated": "Content truncated, tap to view full post", "postTruncated": "Content truncated, tap to view full post",
"copyMessage": "Copy Message",
"authFactor": "Authentication Factor", "authFactor": "Authentication Factor",
"authFactorDelete": "Delete the Factor", "authFactorDelete": "Delete the Factor",
"authFactorDeleteHint": "Are you sure you want to delete this authentication factor? This action cannot be undone.", "authFactorDeleteHint": "Are you sure you want to delete this authentication factor? This action cannot be undone.",
@ -388,10 +378,6 @@
"authDeviceLabelHint": "Enter a name for this device", "authDeviceLabelHint": "Enter a name for this device",
"authDeviceSwipeEditHint": "Swipe left to edit label", "authDeviceSwipeEditHint": "Swipe left to edit label",
"authDeviceSwipeLogoutHint": "Swipe right to logout device", "authDeviceSwipeLogoutHint": "Swipe right to logout device",
"typingHint": {
"one": "{} is typing...",
"other": "{} are typing..."
},
"settingsAppearance": "Appearance", "settingsAppearance": "Appearance",
"settingsServer": "Server", "settingsServer": "Server",
"settingsBehavior": "Behavior", "settingsBehavior": "Behavior",
@ -453,21 +439,6 @@
"contactMethodSetPrimary": "Set as Primary", "contactMethodSetPrimary": "Set as Primary",
"contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications", "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.", "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", "firstName": "First Name",
"middleName": "Middle Name", "middleName": "Middle Name",
"lastName": "Last Name", "lastName": "Last Name",
@ -549,17 +520,49 @@
"quickActions": "Quick Actions", "quickActions": "Quick Actions",
"post": "Post", "post": "Post",
"copy": "Copy", "copy": "Copy",
"sendToChat": "Send to Chat",
"failedToShareToPost": "Failed to share to post: {}", "failedToShareToPost": "Failed to share to post: {}",
"shareToChatComingSoon": "Share to chat functionality coming soon", "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", "systemShareComingSoon": "System share functionality coming soon",
"failedToShareToSystem": "Failed to share to system: {}", "failedToShareToSystem": "Failed to share to system: {}",
"failedToCopy": "Failed to copy: {}", "failedToCopy": "Failed to copy: {}",
"noChatRoomsAvailable": "No chat rooms available",
"failedToLoadChats": "Failed to load chats",
"contentToShare": "Content to share:", "contentToShare": "Content to share:",
"unknownChat": "Unknown Chat" "uploadingFiles": "Uploading files...",
"shareSuccess": "Shared successfully!",
"wouldYouLikeToGoToChat": "Would you like to go to the chat?",
"no": "No",
"yes": "Yes",
"abuseReport": "Report",
"abuseReportTitle": "Report Content",
"abuseReportDescription": "Help us keep the community safe by reporting inappropriate content or behavior.",
"abuseReportType": "Report Type",
"abuseReportReason": "Additional Details",
"abuseReportReasonHint": "Please provide more details about the issue...",
"abuseReportSubmit": "Submit Report",
"abuseReportSuccess": "Report submitted successfully. Thank you for helping keep our community safe.",
"abuseReportError": "Failed to submit report. Please try again.",
"abuseReportReasonRequired": "Please provide details about the issue",
"abuseReportSuccessTitle": "Report Submitted",
"abuseReportErrorTitle": "Error",
"abuseReportTypeSpam": "Spam or Misleading",
"abuseReportTypeHarassment": "Harassment or Abuse",
"abuseReportTypeInappropriate": "Inappropriate Content",
"abuseReportTypeViolence": "Violence or Threats",
"abuseReportTypeCopyright": "Copyright Violation",
"abuseReportTypeImpersonation": "Impersonation",
"abuseReportTypeOffensiveContent": "Offensive Content",
"abuseReportTypePrivacyViolation": "Privacy Violation",
"abuseReportTypeIllegalContent": "Illegal Content",
"abuseReportTypeOther": "Other",
"tags": "Tags",
"tagsHint": "Enter tags, separated by commas",
"categories": "Categories",
"categoriesHint": "Enter categories, separated by commas",
"chatNotJoined": "You have not joined this chat yet.",
"chatUnableJoin": "You can't join this chat due to it's access control settings.",
"chatJoin": "Join the Chat",
"realmJoin": "Join the Realm",
"realmJoinSuccess": "Successfully joined the realm.",
"discoverRealms": "Discover Realms",
"discoverPublishers": "Discover Publishers",
"search": "Search"
} }

2
crowdin.yml Normal file
View File

@ -0,0 +1,2 @@
bundles:
- 6

View File

@ -84,7 +84,7 @@ PODS:
- Flutter - Flutter
- flutter_platform_alert (0.0.1): - flutter_platform_alert (0.0.1):
- Flutter - Flutter
- flutter_secure_storage (3.3.1): - flutter_secure_storage (6.0.0):
- Flutter - Flutter
- flutter_timezone (0.0.1): - flutter_timezone (0.0.1):
- Flutter - Flutter
@ -362,7 +362,7 @@ SPEC CHECKSUMS:
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3 flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3
flutter_secure_storage: 50035aef357c5a8bdd67fd6bc81370d46efc4d16 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
flutter_webrtc: fd0d3bdef8766a0736dbbe2e5b7e85f1f3c52117 flutter_webrtc: fd0d3bdef8766a0736dbbe2e5b7e85f1f3c52117

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,8 @@
<true/> <true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key> <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer> <integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key> <key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>100</integer> <integer>100</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key> <key>NSExtensionActivationSupportsMovieWithMaxCount</key>

View File

@ -9,6 +9,6 @@ import receive_sharing_intent
class ShareViewController: RSIShareViewController { class ShareViewController: RSIShareViewController {
override func shouldAutoRedirect() -> Bool { override func shouldAutoRedirect() -> Bool {
return false return true
} }
} }

View File

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

View File

@ -18,8 +18,8 @@ import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/services/notify.dart'; import 'package:island/services/notify.dart';
import 'package:island/widgets/app_wrapper.dart';
import 'package:island/services/timezone.dart'; import 'package:island/services/timezone.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@ -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:flutter_native_splash/flutter_native_splash.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
log('Handling a background message: ${message.messageId}');
}
void main() async { void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
@ -43,6 +49,7 @@ void main() async {
await Firebase.initializeApp( await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform, options: DefaultFirebaseOptions.currentPlatform,
); );
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
log("[SplashScreen] Firebase is ready!"); log("[SplashScreen] Firebase is ready!");
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
@ -125,7 +132,9 @@ void main() async {
); );
} }
final appRouter = AppRouter(); // Router will be provided through Riverpod
final globalOverlay = GlobalKey<OverlayState>();
class IslandApp extends HookConsumerWidget { class IslandApp extends HookConsumerWidget {
const IslandApp({super.key}); const IslandApp({super.key});
@ -139,7 +148,8 @@ class IslandApp extends HookConsumerWidget {
var uri = notification.data['action_uri'] as String; var uri = notification.data['action_uri'] as String;
if (uri.startsWith('/')) { if (uri.startsWith('/')) {
// In-app routes // In-app routes
appRouter.pushPath(notification.data['action_uri']); final router = ref.read(routerProvider);
router.go(notification.data['action_uri']);
} else { } else {
// External links // External links
launchUrlString(uri); launchUrlString(uri);
@ -148,17 +158,30 @@ class IslandApp extends HookConsumerWidget {
} }
useEffect(() { useEffect(() {
Future(() async { // When the app is opened from a terminated state.
RemoteMessage? initialMessage = FirebaseMessaging.instance.getInitialMessage().then((message) {
await FirebaseMessaging.instance.getInitialMessage(); if (message != null) {
if (initialMessage != null) { handleMessage(message);
handleMessage(initialMessage);
} }
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(() { useEffect(() {
@ -181,11 +204,13 @@ class IslandApp extends HookConsumerWidget {
return null; return null;
}, []); }, []);
final router = ref.watch(routerProvider);
return MaterialApp.router( return MaterialApp.router(
theme: theme?.light, theme: theme?.light,
darkTheme: theme?.dark, darkTheme: theme?.dark,
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
routerConfig: appRouter.config(), routerConfig: router,
supportedLocales: context.supportedLocales, supportedLocales: context.supportedLocales,
localizationsDelegates: [ localizationsDelegates: [
...context.localizationDelegates, ...context.localizationDelegates,
@ -195,13 +220,12 @@ class IslandApp extends HookConsumerWidget {
locale: context.locale, locale: context.locale,
builder: (context, child) { builder: (context, child) {
return Overlay( return Overlay(
key: globalOverlay,
initialEntries: [ initialEntries: [
OverlayEntry( OverlayEntry(
builder: builder:
(_) => WindowScaffold( (_) =>
router: appRouter, WindowScaffold(child: child ?? const SizedBox.shrink()),
child: child ?? const SizedBox.shrink(),
),
), ),
], ],
); );

View File

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

View File

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

View File

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

View File

@ -21,3 +21,22 @@ sealed class SnEmbedLink with _$SnEmbedLink {
factory SnEmbedLink.fromJson(Map<String, dynamic> json) => factory SnEmbedLink.fromJson(Map<String, dynamic> json) =>
_$SnEmbedLinkFromJson(json); _$SnEmbedLinkFromJson(json);
} }
@freezed
sealed class SnScrappedLink with _$SnScrappedLink {
const factory SnScrappedLink({
required String type,
required String url,
required String title,
required String? description,
required String? imageUrl,
required String faviconUrl,
required String siteName,
required String? contentType,
required String? author,
required DateTime? publishedDate,
}) = _SnScrappedLink;
factory SnScrappedLink.fromJson(Map<String, dynamic> json) =>
_$SnScrappedLinkFromJson(json);
}

View File

@ -170,6 +170,166 @@ as DateTime?,
} }
}
/// @nodoc
mixin _$SnScrappedLink {
String get type; String get url; String get title; String? get description; String? get imageUrl; String get faviconUrl; String get siteName; String? get contentType; String? get author; DateTime? get publishedDate;
/// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnScrappedLinkCopyWith<SnScrappedLink> get copyWith => _$SnScrappedLinkCopyWithImpl<SnScrappedLink>(this as SnScrappedLink, _$identity);
/// Serializes this SnScrappedLink to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnScrappedLink&&(identical(other.type, type) || other.type == type)&&(identical(other.url, url) || other.url == url)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.faviconUrl, faviconUrl) || other.faviconUrl == faviconUrl)&&(identical(other.siteName, siteName) || other.siteName == siteName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.author, author) || other.author == author)&&(identical(other.publishedDate, publishedDate) || other.publishedDate == publishedDate));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,type,url,title,description,imageUrl,faviconUrl,siteName,contentType,author,publishedDate);
@override
String toString() {
return 'SnScrappedLink(type: $type, url: $url, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl, siteName: $siteName, contentType: $contentType, author: $author, publishedDate: $publishedDate)';
}
}
/// @nodoc
abstract mixin class $SnScrappedLinkCopyWith<$Res> {
factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl;
@useResult
$Res call({
String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate
});
}
/// @nodoc
class _$SnScrappedLinkCopyWithImpl<$Res>
implements $SnScrappedLinkCopyWith<$Res> {
_$SnScrappedLinkCopyWithImpl(this._self, this._then);
final SnScrappedLink _self;
final $Res Function(SnScrappedLink) _then;
/// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
return _then(_self.copyWith(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable
as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnScrappedLink implements SnScrappedLink {
const _SnScrappedLink({required this.type, required this.url, required this.title, required this.description, required this.imageUrl, required this.faviconUrl, required this.siteName, required this.contentType, required this.author, required this.publishedDate});
factory _SnScrappedLink.fromJson(Map<String, dynamic> json) => _$SnScrappedLinkFromJson(json);
@override final String type;
@override final String url;
@override final String title;
@override final String? description;
@override final String? imageUrl;
@override final String faviconUrl;
@override final String siteName;
@override final String? contentType;
@override final String? author;
@override final DateTime? publishedDate;
/// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnScrappedLinkCopyWith<_SnScrappedLink> get copyWith => __$SnScrappedLinkCopyWithImpl<_SnScrappedLink>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnScrappedLinkToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnScrappedLink&&(identical(other.type, type) || other.type == type)&&(identical(other.url, url) || other.url == url)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)&&(identical(other.faviconUrl, faviconUrl) || other.faviconUrl == faviconUrl)&&(identical(other.siteName, siteName) || other.siteName == siteName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.author, author) || other.author == author)&&(identical(other.publishedDate, publishedDate) || other.publishedDate == publishedDate));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,type,url,title,description,imageUrl,faviconUrl,siteName,contentType,author,publishedDate);
@override
String toString() {
return 'SnScrappedLink(type: $type, url: $url, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl, siteName: $siteName, contentType: $contentType, author: $author, publishedDate: $publishedDate)';
}
}
/// @nodoc
abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCopyWith<$Res> {
factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl;
@override @useResult
$Res call({
String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate
});
}
/// @nodoc
class __$SnScrappedLinkCopyWithImpl<$Res>
implements _$SnScrappedLinkCopyWith<$Res> {
__$SnScrappedLinkCopyWithImpl(this._self, this._then);
final _SnScrappedLink _self;
final $Res Function(_SnScrappedLink) _then;
/// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
return _then(_SnScrappedLink(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable
as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
} }
// dart format on // dart format on

View File

@ -35,3 +35,34 @@ Map<String, dynamic> _$SnEmbedLinkToJson(_SnEmbedLink instance) =>
'Author': instance.author, 'Author': instance.author,
'PublishedDate': instance.publishedDate?.toIso8601String(), 'PublishedDate': instance.publishedDate?.toIso8601String(),
}; };
_SnScrappedLink _$SnScrappedLinkFromJson(Map<String, dynamic> json) =>
_SnScrappedLink(
type: json['type'] as String,
url: json['url'] as String,
title: json['title'] as String,
description: json['description'] as String?,
imageUrl: json['image_url'] as String?,
faviconUrl: json['favicon_url'] as String,
siteName: json['site_name'] as String,
contentType: json['content_type'] as String?,
author: json['author'] as String?,
publishedDate:
json['published_date'] == null
? null
: DateTime.parse(json['published_date'] as String),
);
Map<String, dynamic> _$SnScrappedLinkToJson(_SnScrappedLink instance) =>
<String, dynamic>{
'type': instance.type,
'url': instance.url,
'title': instance.title,
'description': instance.description,
'image_url': instance.imageUrl,
'favicon_url': instance.faviconUrl,
'site_name': instance.siteName,
'content_type': instance.contentType,
'author': instance.author,
'published_date': instance.publishedDate?.toIso8601String(),
};

View File

@ -1,5 +1,7 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/post_category.dart';
import 'package:island/models/post_tag.dart';
import 'package:island/models/user.dart'; import 'package:island/models/user.dart';
part 'post.freezed.dart'; part 'post.freezed.dart';
@ -33,8 +35,8 @@ sealed class SnPost with _$SnPost {
@Default(SnPublisher()) SnPublisher publisher, @Default(SnPublisher()) SnPublisher publisher,
@Default({}) Map<String, int> reactionsCount, @Default({}) Map<String, int> reactionsCount,
@Default([]) List<dynamic> reactions, @Default([]) List<dynamic> reactions,
@Default([]) List<dynamic> tags, @Default([]) List<PostTag> tags,
@Default([]) List<dynamic> categories, @Default([]) List<PostCategory> categories,
@Default([]) List<dynamic> collections, @Default([]) List<dynamic> collections,
@Default(null) DateTime? createdAt, @Default(null) DateTime? createdAt,
@Default(null) DateTime? updatedAt, @Default(null) DateTime? updatedAt,

View File

@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$SnPost { mixin _$SnPost {
String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime? get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<dynamic> get tags; List<dynamic> get categories; List<dynamic> get collections; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; bool get isTruncated; String get id; String? get title; String? get description; String? get language; DateTime? get editedAt; DateTime? get publishedAt; int get visibility; String? get content; int get type; Map<String, dynamic>? get meta; int get viewsUnique; int get viewsTotal; int get upvotes; int get downvotes; int get repliesCount; String? get threadedPostId; SnPost? get threadedPost; String? get repliedPostId; SnPost? get repliedPost; String? get forwardedPostId; SnPost? get forwardedPost; List<SnCloudFile> get attachments; SnPublisher get publisher; Map<String, int> get reactionsCount; List<dynamic> get reactions; List<PostTag> get tags; List<PostCategory> get categories; List<dynamic> get collections; DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; bool get isTruncated;
/// Create a copy of SnPost /// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -49,7 +49,7 @@ abstract mixin class $SnPostCopyWith<$Res> {
factory $SnPostCopyWith(SnPost value, $Res Function(SnPost) _then) = _$SnPostCopyWithImpl; factory $SnPostCopyWith(SnPost value, $Res Function(SnPost) _then) = _$SnPostCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<PostTag> tags, List<PostCategory> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated
}); });
@ -94,8 +94,8 @@ as List<SnCloudFile>,publisher: null == publisher ? _self.publisher : publisher
as SnPublisher,reactionsCount: null == reactionsCount ? _self.reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable as SnPublisher,reactionsCount: null == reactionsCount ? _self.reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable
as Map<String, int>,reactions: null == reactions ? _self.reactions : reactions // ignore: cast_nullable_to_non_nullable as Map<String, int>,reactions: null == reactions ? _self.reactions : reactions // ignore: cast_nullable_to_non_nullable
as List<dynamic>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable as List<dynamic>,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable
as List<dynamic>,categories: null == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable as List<PostTag>,categories: null == categories ? _self.categories : categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>,collections: null == collections ? _self.collections : collections // ignore: cast_nullable_to_non_nullable as List<PostCategory>,collections: null == collections ? _self.collections : collections // ignore: cast_nullable_to_non_nullable
as List<dynamic>,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as List<dynamic>,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
@ -156,7 +156,7 @@ $SnPublisherCopyWith<$Res> get publisher {
@JsonSerializable() @JsonSerializable()
class _SnPost implements SnPost { class _SnPost implements SnPost {
const _SnPost({required this.id, this.title, this.description, this.language, this.editedAt, this.publishedAt = null, this.visibility = 0, this.content, this.type = 0, final Map<String, dynamic>? meta, this.viewsUnique = 0, this.viewsTotal = 0, this.upvotes = 0, this.downvotes = 0, this.repliesCount = 0, this.threadedPostId, this.threadedPost, this.repliedPostId, this.repliedPost, this.forwardedPostId, this.forwardedPost, final List<SnCloudFile> attachments = const [], this.publisher = const SnPublisher(), final Map<String, int> reactionsCount = const {}, final List<dynamic> reactions = const [], final List<dynamic> tags = const [], final List<dynamic> categories = const [], final List<dynamic> collections = const [], this.createdAt = null, this.updatedAt = null, this.deletedAt, this.isTruncated = false}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections; const _SnPost({required this.id, this.title, this.description, this.language, this.editedAt, this.publishedAt = null, this.visibility = 0, this.content, this.type = 0, final Map<String, dynamic>? meta, this.viewsUnique = 0, this.viewsTotal = 0, this.upvotes = 0, this.downvotes = 0, this.repliesCount = 0, this.threadedPostId, this.threadedPost, this.repliedPostId, this.repliedPost, this.forwardedPostId, this.forwardedPost, final List<SnCloudFile> attachments = const [], this.publisher = const SnPublisher(), final Map<String, int> reactionsCount = const {}, final List<dynamic> reactions = const [], final List<PostTag> tags = const [], final List<PostCategory> categories = const [], final List<dynamic> collections = const [], this.createdAt = null, this.updatedAt = null, this.deletedAt, this.isTruncated = false}): _meta = meta,_attachments = attachments,_reactionsCount = reactionsCount,_reactions = reactions,_tags = tags,_categories = categories,_collections = collections;
factory _SnPost.fromJson(Map<String, dynamic> json) => _$SnPostFromJson(json); factory _SnPost.fromJson(Map<String, dynamic> json) => _$SnPostFromJson(json);
@override final String id; @override final String id;
@ -210,15 +210,15 @@ class _SnPost implements SnPost {
return EqualUnmodifiableListView(_reactions); return EqualUnmodifiableListView(_reactions);
} }
final List<dynamic> _tags; final List<PostTag> _tags;
@override@JsonKey() List<dynamic> get tags { @override@JsonKey() List<PostTag> get tags {
if (_tags is EqualUnmodifiableListView) return _tags; if (_tags is EqualUnmodifiableListView) return _tags;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tags); return EqualUnmodifiableListView(_tags);
} }
final List<dynamic> _categories; final List<PostCategory> _categories;
@override@JsonKey() List<dynamic> get categories { @override@JsonKey() List<PostCategory> get categories {
if (_categories is EqualUnmodifiableListView) return _categories; if (_categories is EqualUnmodifiableListView) return _categories;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_categories); return EqualUnmodifiableListView(_categories);
@ -269,7 +269,7 @@ abstract mixin class _$SnPostCopyWith<$Res> implements $SnPostCopyWith<$Res> {
factory _$SnPostCopyWith(_SnPost value, $Res Function(_SnPost) _then) = __$SnPostCopyWithImpl; factory _$SnPostCopyWith(_SnPost value, $Res Function(_SnPost) _then) = __$SnPostCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<dynamic> tags, List<dynamic> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated String id, String? title, String? description, String? language, DateTime? editedAt, DateTime? publishedAt, int visibility, String? content, int type, Map<String, dynamic>? meta, int viewsUnique, int viewsTotal, int upvotes, int downvotes, int repliesCount, String? threadedPostId, SnPost? threadedPost, String? repliedPostId, SnPost? repliedPost, String? forwardedPostId, SnPost? forwardedPost, List<SnCloudFile> attachments, SnPublisher publisher, Map<String, int> reactionsCount, List<dynamic> reactions, List<PostTag> tags, List<PostCategory> categories, List<dynamic> collections, DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, bool isTruncated
}); });
@ -314,8 +314,8 @@ as List<SnCloudFile>,publisher: null == publisher ? _self.publisher : publisher
as SnPublisher,reactionsCount: null == reactionsCount ? _self._reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable as SnPublisher,reactionsCount: null == reactionsCount ? _self._reactionsCount : reactionsCount // ignore: cast_nullable_to_non_nullable
as Map<String, int>,reactions: null == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable as Map<String, int>,reactions: null == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable
as List<dynamic>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable as List<dynamic>,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable
as List<dynamic>,categories: null == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable as List<PostTag>,categories: null == categories ? _self._categories : categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>,collections: null == collections ? _self._collections : collections // ignore: cast_nullable_to_non_nullable as List<PostCategory>,collections: null == collections ? _self._collections : collections // ignore: cast_nullable_to_non_nullable
as List<dynamic>,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable as List<dynamic>,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable

View File

@ -58,8 +58,16 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
) ?? ) ??
const {}, const {},
reactions: json['reactions'] as List<dynamic>? ?? const [], reactions: json['reactions'] as List<dynamic>? ?? const [],
tags: json['tags'] as List<dynamic>? ?? const [], tags:
categories: json['categories'] as List<dynamic>? ?? const [], (json['tags'] as List<dynamic>?)
?.map((e) => PostTag.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
categories:
(json['categories'] as List<dynamic>?)
?.map((e) => PostCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
collections: json['collections'] as List<dynamic>? ?? const [], collections: json['collections'] as List<dynamic>? ?? const [],
createdAt: createdAt:
json['created_at'] == null json['created_at'] == null
@ -102,8 +110,8 @@ Map<String, dynamic> _$SnPostToJson(_SnPost instance) => <String, dynamic>{
'publisher': instance.publisher.toJson(), 'publisher': instance.publisher.toJson(),
'reactions_count': instance.reactionsCount, 'reactions_count': instance.reactionsCount,
'reactions': instance.reactions, 'reactions': instance.reactions,
'tags': instance.tags, 'tags': instance.tags.map((e) => e.toJson()).toList(),
'categories': instance.categories, 'categories': instance.categories.map((e) => e.toJson()).toList(),
'collections': instance.collections, 'collections': instance.collections,
'created_at': instance.createdAt?.toIso8601String(), 'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(), 'updated_at': instance.updatedAt?.toIso8601String(),

View File

@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/post.dart';
part 'post_category.freezed.dart';
part 'post_category.g.dart';
@freezed
sealed class PostCategory with _$PostCategory {
const factory PostCategory({
required String id,
required String slug,
String? name,
@Default([]) List<SnPost> posts,
}) = _PostCategory;
factory PostCategory.fromJson(Map<String, dynamic> json) =>
_$PostCategoryFromJson(json);
}

View File

@ -0,0 +1,163 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'post_category.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PostCategory {
String get id; String get slug; String? get name; List<SnPost> get posts;
/// Create a copy of PostCategory
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PostCategoryCopyWith<PostCategory> get copyWith => _$PostCategoryCopyWithImpl<PostCategory>(this as PostCategory, _$identity);
/// Serializes this PostCategory to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PostCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.posts, posts));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(posts));
@override
String toString() {
return 'PostCategory(id: $id, slug: $slug, name: $name, posts: $posts)';
}
}
/// @nodoc
abstract mixin class $PostCategoryCopyWith<$Res> {
factory $PostCategoryCopyWith(PostCategory value, $Res Function(PostCategory) _then) = _$PostCategoryCopyWithImpl;
@useResult
$Res call({
String id, String slug, String? name, List<SnPost> posts
});
}
/// @nodoc
class _$PostCategoryCopyWithImpl<$Res>
implements $PostCategoryCopyWith<$Res> {
_$PostCategoryCopyWithImpl(this._self, this._then);
final PostCategory _self;
final $Res Function(PostCategory) _then;
/// Create a copy of PostCategory
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable
as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String?,posts: null == posts ? _self.posts : posts // ignore: cast_nullable_to_non_nullable
as List<SnPost>,
));
}
}
/// @nodoc
@JsonSerializable()
class _PostCategory implements PostCategory {
const _PostCategory({required this.id, required this.slug, this.name, final List<SnPost> posts = const []}): _posts = posts;
factory _PostCategory.fromJson(Map<String, dynamic> json) => _$PostCategoryFromJson(json);
@override final String id;
@override final String slug;
@override final String? name;
final List<SnPost> _posts;
@override@JsonKey() List<SnPost> get posts {
if (_posts is EqualUnmodifiableListView) return _posts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_posts);
}
/// Create a copy of PostCategory
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$PostCategoryCopyWith<_PostCategory> get copyWith => __$PostCategoryCopyWithImpl<_PostCategory>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$PostCategoryToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._posts, _posts));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(_posts));
@override
String toString() {
return 'PostCategory(id: $id, slug: $slug, name: $name, posts: $posts)';
}
}
/// @nodoc
abstract mixin class _$PostCategoryCopyWith<$Res> implements $PostCategoryCopyWith<$Res> {
factory _$PostCategoryCopyWith(_PostCategory value, $Res Function(_PostCategory) _then) = __$PostCategoryCopyWithImpl;
@override @useResult
$Res call({
String id, String slug, String? name, List<SnPost> posts
});
}
/// @nodoc
class __$PostCategoryCopyWithImpl<$Res>
implements _$PostCategoryCopyWith<$Res> {
__$PostCategoryCopyWithImpl(this._self, this._then);
final _PostCategory _self;
final $Res Function(_PostCategory) _then;
/// Create a copy of PostCategory
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) {
return _then(_PostCategory(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable
as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String?,posts: null == posts ? _self._posts : posts // ignore: cast_nullable_to_non_nullable
as List<SnPost>,
));
}
}
// dart format on

View File

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'post_category.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_PostCategory _$PostCategoryFromJson(Map<String, dynamic> json) =>
_PostCategory(
id: json['id'] as String,
slug: json['slug'] as String,
name: json['name'] as String?,
posts:
(json['posts'] as List<dynamic>?)
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$PostCategoryToJson(_PostCategory instance) =>
<String, dynamic>{
'id': instance.id,
'slug': instance.slug,
'name': instance.name,
'posts': instance.posts.map((e) => e.toJson()).toList(),
};

19
lib/models/post_tag.dart Normal file
View File

@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/post.dart';
part 'post_tag.freezed.dart';
part 'post_tag.g.dart';
@freezed
sealed class PostTag with _$PostTag {
const factory PostTag({
required String id,
required String slug,
String? name,
@Default([]) List<SnPost> posts,
}) = _PostTag;
factory PostTag.fromJson(Map<String, dynamic> json) =>
_$PostTagFromJson(json);
}

View File

@ -0,0 +1,163 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'post_tag.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PostTag {
String get id; String get slug; String? get name; List<SnPost> get posts;
/// Create a copy of PostTag
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PostTagCopyWith<PostTag> get copyWith => _$PostTagCopyWithImpl<PostTag>(this as PostTag, _$identity);
/// Serializes this PostTag to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PostTag&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.posts, posts));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(posts));
@override
String toString() {
return 'PostTag(id: $id, slug: $slug, name: $name, posts: $posts)';
}
}
/// @nodoc
abstract mixin class $PostTagCopyWith<$Res> {
factory $PostTagCopyWith(PostTag value, $Res Function(PostTag) _then) = _$PostTagCopyWithImpl;
@useResult
$Res call({
String id, String slug, String? name, List<SnPost> posts
});
}
/// @nodoc
class _$PostTagCopyWithImpl<$Res>
implements $PostTagCopyWith<$Res> {
_$PostTagCopyWithImpl(this._self, this._then);
final PostTag _self;
final $Res Function(PostTag) _then;
/// Create a copy of PostTag
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable
as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String?,posts: null == posts ? _self.posts : posts // ignore: cast_nullable_to_non_nullable
as List<SnPost>,
));
}
}
/// @nodoc
@JsonSerializable()
class _PostTag implements PostTag {
const _PostTag({required this.id, required this.slug, this.name, final List<SnPost> posts = const []}): _posts = posts;
factory _PostTag.fromJson(Map<String, dynamic> json) => _$PostTagFromJson(json);
@override final String id;
@override final String slug;
@override final String? name;
final List<SnPost> _posts;
@override@JsonKey() List<SnPost> get posts {
if (_posts is EqualUnmodifiableListView) return _posts;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_posts);
}
/// Create a copy of PostTag
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$PostTagCopyWith<_PostTag> get copyWith => __$PostTagCopyWithImpl<_PostTag>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$PostTagToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostTag&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._posts, _posts));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(_posts));
@override
String toString() {
return 'PostTag(id: $id, slug: $slug, name: $name, posts: $posts)';
}
}
/// @nodoc
abstract mixin class _$PostTagCopyWith<$Res> implements $PostTagCopyWith<$Res> {
factory _$PostTagCopyWith(_PostTag value, $Res Function(_PostTag) _then) = __$PostTagCopyWithImpl;
@override @useResult
$Res call({
String id, String slug, String? name, List<SnPost> posts
});
}
/// @nodoc
class __$PostTagCopyWithImpl<$Res>
implements _$PostTagCopyWith<$Res> {
__$PostTagCopyWithImpl(this._self, this._then);
final _PostTag _self;
final $Res Function(_PostTag) _then;
/// Create a copy of PostTag
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) {
return _then(_PostTag(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable
as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String?,posts: null == posts ? _self._posts : posts // ignore: cast_nullable_to_non_nullable
as List<SnPost>,
));
}
}
// dart format on

View File

@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'post_tag.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_PostTag _$PostTagFromJson(Map<String, dynamic> json) => _PostTag(
id: json['id'] as String,
slug: json['slug'] as String,
name: json['name'] as String?,
posts:
(json['posts'] as List<dynamic>?)
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$PostTagToJson(_PostTag instance) => <String, dynamic>{
'id': instance.id,
'slug': instance.slug,
'name': instance.name,
'posts': instance.posts.map((e) => e.toJson()).toList(),
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/embed.dart';
import 'package:island/pods/network.dart';
part 'link_preview.g.dart';
@riverpod
class LinkPreview extends _$LinkPreview {
@override
Future<SnScrappedLink?> build(String url) async {
final client = ref.read(apiClientProvider);
try {
final response = await client.get(
'/scrap/link',
queryParameters: {'url': url},
);
if (response.statusCode == 200 && response.data != null) {
return SnScrappedLink.fromJson(response.data);
}
return null;
} catch (e) {
// Return null on error to show fallback UI
return null;
}
}
}

View File

@ -0,0 +1,164 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'link_preview.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$linkPreviewHash() => r'5130593d3066155cb958d20714ee577df1f940d7';
/// 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 _$LinkPreview
extends BuildlessAutoDisposeAsyncNotifier<SnScrappedLink?> {
late final String url;
FutureOr<SnScrappedLink?> build(String url);
}
/// See also [LinkPreview].
@ProviderFor(LinkPreview)
const linkPreviewProvider = LinkPreviewFamily();
/// See also [LinkPreview].
class LinkPreviewFamily extends Family<AsyncValue<SnScrappedLink?>> {
/// See also [LinkPreview].
const LinkPreviewFamily();
/// See also [LinkPreview].
LinkPreviewProvider call(String url) {
return LinkPreviewProvider(url);
}
@override
LinkPreviewProvider getProviderOverride(
covariant LinkPreviewProvider provider,
) {
return call(provider.url);
}
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'linkPreviewProvider';
}
/// See also [LinkPreview].
class LinkPreviewProvider
extends AutoDisposeAsyncNotifierProviderImpl<LinkPreview, SnScrappedLink?> {
/// See also [LinkPreview].
LinkPreviewProvider(String url)
: this._internal(
() => LinkPreview()..url = url,
from: linkPreviewProvider,
name: r'linkPreviewProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$linkPreviewHash,
dependencies: LinkPreviewFamily._dependencies,
allTransitiveDependencies: LinkPreviewFamily._allTransitiveDependencies,
url: url,
);
LinkPreviewProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.url,
}) : super.internal();
final String url;
@override
FutureOr<SnScrappedLink?> runNotifierBuild(covariant LinkPreview notifier) {
return notifier.build(url);
}
@override
Override overrideWith(LinkPreview Function() create) {
return ProviderOverride(
origin: this,
override: LinkPreviewProvider._internal(
() => create()..url = url,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
url: url,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<LinkPreview, SnScrappedLink?>
createElement() {
return _LinkPreviewProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is LinkPreviewProvider && other.url == url;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, url.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin LinkPreviewRef on AutoDisposeAsyncNotifierProviderRef<SnScrappedLink?> {
/// The parameter `url` of this provider.
String get url;
}
class _LinkPreviewProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<LinkPreview, SnScrappedLink?>
with LinkPreviewRef {
_LinkPreviewProviderElement(super.provider);
@override
String get url => (origin as LinkPreviewProvider).url;
}
// 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

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

View File

@ -1,95 +1,327 @@
import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart';
import 'package:island/route.gr.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/app_wrapper.dart';
import 'package:island/screens/tabs.dart';
@AutoRouterConfig(replaceInRouteName: 'Screen|Page,Route') import 'package:island/screens/explore.dart';
class AppRouter extends RootStackRouter { import 'package:island/screens/account.dart';
@override import 'package:island/screens/notification.dart';
RouteType get defaultRouteType => RouteType.adaptive(); import 'package:island/screens/wallet.dart';
import 'package:island/screens/account/relationship.dart';
import 'package:island/screens/account/profile.dart';
import 'package:island/screens/account/me/update.dart';
import 'package:island/screens/account/leveling.dart';
import 'package:island/screens/account/me/settings.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:island/screens/chat/room.dart';
import 'package:island/screens/chat/room_detail.dart';
import 'package:island/screens/chat/call.dart';
import 'package:island/screens/creators/hub.dart';
import 'package:island/screens/creators/posts/list.dart';
import 'package:island/screens/creators/stickers/stickers.dart';
import 'package:island/screens/creators/stickers/pack_detail.dart';
import 'package:island/screens/creators/publishers.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/detail.dart';
import 'package:island/screens/posts/pub_profile.dart';
import 'package:island/screens/auth/login.dart';
import 'package:island/screens/auth/create_account.dart';
import 'package:island/screens/settings.dart';
import 'package:island/screens/realm/realms.dart';
import 'package:island/screens/realm/detail.dart';
import 'package:island/screens/account/event_calendar.dart';
import 'package:island/screens/discovery/realms.dart';
@override // Shell route keys for nested navigation
List<AutoRoute> get routes => [ final rootNavigatorKey = GlobalKey<NavigatorState>();
AutoRoute(path: '/', page: AppWrapper.page, children: _appRoutes), final _shellNavigatorKey = GlobalKey<NavigatorState>();
]; final _tabsShellKey = GlobalKey<NavigatorState>();
List<AutoRoute> get _appRoutes => [ // Provider for the router
AutoRoute(page: PostComposeRoute.page, path: 'posts/compose'), final routerProvider = Provider<GoRouter>((ref) {
AutoRoute(page: PostEditRoute.page, path: 'posts/:id/edit'), return GoRouter(
AutoRoute(page: CallRoute.page, path: 'chat/:id/call'), navigatorKey: rootNavigatorKey,
AutoRoute(page: EventCalanderRoute.page, path: 'account/:name/calendar'), initialLocation: '/',
AutoRoute( routes: [
page: TabsRoute.page, ShellRoute(
path: '', navigatorKey: _shellNavigatorKey,
children: [ builder: (context, state, child) {
AutoRoute( return AppWrapper(child: child);
page: ExploreShellRoute.page, },
path: '', routes: [
children: [ // Standalone routes without bottom navigation
AutoRoute(page: ExploreRoute.page, path: ''), GoRoute(
AutoRoute(page: PostDetailRoute.page, path: 'posts/:id'), path: '/posts/compose',
AutoRoute( builder:
page: PublisherProfileRoute.page, (context, state) => PostComposeScreen(
path: 'publishers/:name', initialState: state.extra as PostComposeInitialState?,
), ),
], ),
), GoRoute(
AutoRoute( path: '/posts/:id/edit',
page: AccountShellRoute.page, builder: (context, state) {
path: 'account', final id = state.pathParameters['id']!;
children: [ return PostEditScreen(id: id);
AutoRoute(page: AccountRoute.page, path: ''), },
AutoRoute(page: NotificationRoute.page, path: 'notifications'), ),
AutoRoute(page: WalletRoute.page, path: 'wallet'), GoRoute(
AutoRoute(page: RelationshipRoute.page, path: 'relationships'), path: '/chat/:id/call',
AutoRoute(page: AccountProfileRoute.page, path: ':name'), builder: (context, state) {
AutoRoute(page: UpdateProfileRoute.page, path: 'me/update'), final id = state.pathParameters['id']!;
AutoRoute(page: LevelingRoute.page, path: 'me/leveling'), return CallScreen(roomId: id);
AutoRoute(page: AccountSettingsRoute.page, path: 'settings'), },
], ),
), GoRoute(
AutoRoute(page: RealmListRoute.page, path: 'realms'), path: '/account/:name/calendar',
AutoRoute( builder: (context, state) {
page: ChatShellRoute.page, final name = state.pathParameters['name']!;
path: 'chat', return EventCalanderScreen(name: name);
children: [ },
AutoRoute(page: ChatListRoute.page, path: ''), ),
AutoRoute(page: ChatRoomRoute.page, path: ':id'), GoRoute(
AutoRoute(page: NewChatRoute.page, path: 'new'), path: '/creators',
AutoRoute(page: EditChatRoute.page, path: ':id/edit'), builder: (context, state) => const CreatorHubScreen(),
AutoRoute(page: ChatDetailRoute.page, path: ':id/detail'), routes: [
], GoRoute(
), path: ':name/posts',
], builder: (context, state) {
), final name = state.pathParameters['name']!;
AutoRoute( return CreatorPostListScreen(pubName: name);
page: CreatorHubShellRoute.page, },
path: 'creators', ),
children: [ GoRoute(
AutoRoute(page: CreatorHubRoute.page, path: ''), path: ':name/stickers',
AutoRoute(page: CreatorPostListRoute.page, path: ':name/posts'), builder: (context, state) {
AutoRoute(page: StickersRoute.page, path: ':name/stickers'), final name = state.pathParameters['name']!;
AutoRoute(page: NewStickerPacksRoute.page, path: ':name/stickers/new'), return StickersScreen(pubName: name);
AutoRoute( },
page: EditStickerPacksRoute.page, ),
path: ':name/stickers/:packId/edit', GoRoute(
), path: ':name/stickers/new',
AutoRoute( builder: (context, state) {
page: StickerPackDetailRoute.page, final name = state.pathParameters['name']!;
path: ':name/stickers/:packId', return NewStickerPacksScreen(pubName: name);
), },
AutoRoute(page: NewStickersRoute.page, path: ':name/stickers/new'), ),
AutoRoute( GoRoute(
page: EditStickersRoute.page, path: ':name/stickers/:packId/edit',
path: ':name/stickers/:id/edit', builder: (context, state) {
), final name = state.pathParameters['name']!;
AutoRoute(page: NewPublisherRoute.page, path: 'new'), final packId = state.pathParameters['packId']!;
AutoRoute(page: EditPublisherRoute.page, path: ':name/edit'), return EditStickerPacksScreen(pubName: name, packId: packId);
], },
), ),
AutoRoute(page: LoginRoute.page, path: 'auth/login'), GoRoute(
AutoRoute(page: CreateAccountRoute.page, path: 'auth/create-account'), path: ':name/stickers/:packId',
AutoRoute(page: SettingsRoute.page, path: 'settings'), builder: (context, state) {
AutoRoute(page: NewRealmRoute.page, path: 'realms/new'), final name = state.pathParameters['name']!;
AutoRoute(page: RealmDetailRoute.page, path: 'realms/:slug'), final packId = state.pathParameters['packId']!;
AutoRoute(page: EditRealmRoute.page, path: 'realms/:slug/edit'), return StickerPackDetailScreen(pubName: name, id: packId);
]; },
),
GoRoute(
path: ':name/stickers/:packId/new',
builder: (context, state) {
final packId = state.pathParameters['packId']!;
return NewStickersScreen(packId: packId);
},
),
GoRoute(
path: ':name/stickers/:packId/:id/edit',
builder: (context, state) {
final packId = state.pathParameters['packId']!;
final id = state.pathParameters['id']!;
return EditStickersScreen(id: id, packId: packId);
},
),
GoRoute(
path: 'new',
builder: (context, state) => const NewPublisherScreen(),
),
GoRoute(
path: ':name/edit',
builder: (context, state) {
final name = state.pathParameters['name']!;
return EditPublisherScreen(name: name);
},
),
],
),
// Auth routes
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/create-account',
builder: (context, state) => const CreateAccountScreen(),
),
// Other routes
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
// Main tabs with TabsScreen shell
ShellRoute(
navigatorKey: _tabsShellKey,
builder: (context, state, child) {
return TabsScreen(child: child);
},
routes: [
// Explore tab
GoRoute(
path: '/',
builder: (context, state) => const ExploreScreen(),
routes: [
GoRoute(
path: 'posts/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return PostDetailScreen(id: id);
},
),
GoRoute(
path: 'publishers/:name',
builder: (context, state) {
final name = state.pathParameters['name']!;
return PublisherProfileScreen(name: name);
},
),
GoRoute(
path: 'discovery/realms',
builder: (context, state) => const DiscoveryRealmsScreen(),
),
],
),
// Chat tab
GoRoute(
path: '/chat',
builder: (context, state) => const ChatListScreen(),
routes: [
GoRoute(
path: 'new',
builder: (context, state) => const NewChatScreen(),
),
GoRoute(
path: ':id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ChatRoomScreen(id: id);
},
),
GoRoute(
path: ':id/edit',
builder: (context, state) {
final id = state.pathParameters['id']!;
return EditChatScreen(id: id);
},
),
GoRoute(
path: ':id/detail',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ChatDetailScreen(id: id);
},
),
],
),
// Realms tab
GoRoute(
path: '/realms',
builder: (context, state) => const RealmListScreen(),
routes: [
GoRoute(
path: 'new',
builder: (context, state) => const NewRealmScreen(),
),
GoRoute(
path: ':slug',
builder: (context, state) {
final slug = state.pathParameters['slug']!;
return RealmDetailScreen(slug: slug);
},
),
GoRoute(
path: ':slug/edit',
builder: (context, state) {
final slug = state.pathParameters['slug']!;
return EditRealmScreen(slug: slug);
},
),
],
),
// Account tab
GoRoute(
path: '/account',
builder: (context, state) => const AccountScreen(),
routes: [
GoRoute(
path: 'notifications',
builder: (context, state) => const NotificationScreen(),
),
GoRoute(
path: 'wallet',
builder: (context, state) => const WalletScreen(),
),
GoRoute(
path: 'relationships',
builder: (context, state) => const RelationshipScreen(),
),
GoRoute(
path: ':name',
builder: (context, state) {
final name = state.pathParameters['name']!;
return AccountProfileScreen(name: name);
},
),
GoRoute(
path: 'me/update',
builder: (context, state) => const UpdateProfileScreen(),
),
GoRoute(
path: 'me/leveling',
builder: (context, state) => const LevelingScreen(),
),
GoRoute(
path: 'settings',
builder: (context, state) => const AccountSettingsScreen(),
),
],
),
],
),
],
),
],
);
});
// Navigation helper functions
class AppRouter {
static GoRouter of(BuildContext context) {
return GoRouter.of(context);
}
static void go(BuildContext context, String path) {
context.go(path);
}
static void push(BuildContext context, String path) {
context.push(path);
}
static void pop(BuildContext context) {
context.pop();
}
static bool canPop(BuildContext context) {
return context.canPop();
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,13 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/message.dart'; import 'package:island/pods/message.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/notification.dart'; import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_name.dart'; import 'package:island/widgets/account/account_name.dart';
@ -19,9 +18,9 @@ import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@RoutePage()
class AccountShellScreen extends HookConsumerWidget { class AccountShellScreen extends HookConsumerWidget {
const AccountShellScreen({super.key}); final Widget child;
const AccountShellScreen({super.key, required this.child});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -34,17 +33,16 @@ class AccountShellScreen extends HookConsumerWidget {
children: [ children: [
Flexible(flex: 2, child: AccountScreen(isAside: true)), Flexible(flex: 2, child: AccountScreen(isAside: true)),
VerticalDivider(width: 1), VerticalDivider(width: 1),
Flexible(flex: 3, child: AutoRouter()), Flexible(flex: 3, child: child),
], ],
), ),
); );
} }
return AppBackground(isRoot: true, child: AutoRouter()); return AppBackground(isRoot: true, child: child);
} }
} }
@RoutePage()
class AccountScreen extends HookConsumerWidget { class AccountScreen extends HookConsumerWidget {
final bool isAside; final bool isAside;
const AccountScreen({super.key, this.isAside = false}); const AccountScreen({super.key, this.isAside = false});
@ -100,9 +98,7 @@ class AccountScreen extends HookConsumerWidget {
radius: 24, radius: 24,
), ),
onTap: () { onTap: () {
context.router.push( context.push('/account/${user.value!.name}');
AccountProfileRoute(name: user.value!.name),
);
}, },
), ),
Expanded( Expanded(
@ -147,7 +143,7 @@ class AccountScreen extends HookConsumerWidget {
progress: user.value!.profile.levelingProgress, progress: user.value!.profile.levelingProgress,
), ),
onTap: () { onTap: () {
context.router.push(LevelingRoute()); context.push('/account/leveling');
}, },
).padding(horizontal: 12), ).padding(horizontal: 12),
Row( Row(
@ -165,7 +161,7 @@ class AccountScreen extends HookConsumerWidget {
], ],
).padding(horizontal: 16, vertical: 12), ).padding(horizontal: 16, vertical: 12),
onTap: () { onTap: () {
context.router.push(CreatorHubShellRoute()); context.push('/creators');
}, },
), ),
).height(140), ).height(140),
@ -204,7 +200,7 @@ class AccountScreen extends HookConsumerWidget {
], ],
), ),
onTap: () { onTap: () {
context.router.push(NotificationRoute()); context.push('/account/notifications');
}, },
), ),
ListTile( ListTile(
@ -214,7 +210,7 @@ class AccountScreen extends HookConsumerWidget {
contentPadding: EdgeInsets.symmetric(horizontal: 24), contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('wallet').tr(), title: Text('wallet').tr(),
onTap: () { onTap: () {
context.router.push(WalletRoute()); context.push('/wallet');
}, },
), ),
ListTile( ListTile(
@ -224,7 +220,7 @@ class AccountScreen extends HookConsumerWidget {
contentPadding: EdgeInsets.symmetric(horizontal: 24), contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('relationships').tr(), title: Text('relationships').tr(),
onTap: () { onTap: () {
context.router.push(RelationshipRoute()); context.push('/account/relationship');
}, },
), ),
const Divider(height: 1).padding(vertical: 8), const Divider(height: 1).padding(vertical: 8),
@ -235,7 +231,7 @@ class AccountScreen extends HookConsumerWidget {
contentPadding: EdgeInsets.symmetric(horizontal: 24), contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('appSettings').tr(), title: Text('appSettings').tr(),
onTap: () { onTap: () {
context.router.push(SettingsRoute()); context.push('/settings');
}, },
), ),
ListTile( ListTile(
@ -245,7 +241,7 @@ class AccountScreen extends HookConsumerWidget {
contentPadding: EdgeInsets.symmetric(horizontal: 24), contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('updateYourProfile').tr(), title: Text('updateYourProfile').tr(),
onTap: () { onTap: () {
context.router.push(UpdateProfileRoute()); context.push('/account/me/update');
}, },
), ),
ListTile( ListTile(
@ -255,7 +251,7 @@ class AccountScreen extends HookConsumerWidget {
contentPadding: EdgeInsets.symmetric(horizontal: 24), contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('accountSettings').tr(), title: Text('accountSettings').tr(),
onTap: () { onTap: () {
context.router.push(AccountSettingsRoute()); context.push('/account/me/settings');
}, },
), ),
if (kDebugMode) const Divider(height: 1).padding(vertical: 8), if (kDebugMode) const Divider(height: 1).padding(vertical: 8),
@ -320,7 +316,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
child: Card( child: Card(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
context.router.push(CreateAccountRoute()); context.push('/auth/create');
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -342,7 +338,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
child: Card( child: Card(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
context.router.push(LoginRoute()); context.push('/auth/login');
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -361,7 +357,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
const Gap(8), const Gap(8),
TextButton( TextButton(
onPressed: () { onPressed: () {
context.router.push(SettingsRoute()); context.push('/settings');
}, },
child: Text('appSettings').tr(), child: Text('appSettings').tr(),
).center(), ).center(),

View File

@ -1,4 +1,3 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@ -12,10 +11,9 @@ import 'package:island/widgets/account/event_calendar.dart';
import 'package:island/widgets/account/fortune_graph.dart'; import 'package:island/widgets/account/fortune_graph.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@RoutePage()
class EventCalanderScreen extends HookConsumerWidget { class EventCalanderScreen extends HookConsumerWidget {
final String name; final String name;
const EventCalanderScreen({super.key, @PathParam("name") required this.name}); const EventCalanderScreen({super.key, required this.name});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {

View File

@ -1,4 +1,3 @@
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -31,7 +30,6 @@ Future<SnWalletSubscription?> accountStellarSubscription(Ref ref) async {
} }
} }
@RoutePage()
class LevelingScreen extends HookConsumerWidget { class LevelingScreen extends HookConsumerWidget {
const LevelingScreen({super.key}); const LevelingScreen({super.key});
@ -641,7 +639,7 @@ class LevelingScreen extends HookConsumerWidget {
ref.invalidate(accountStellarSubscriptionProvider); ref.invalidate(accountStellarSubscriptionProvider);
ref.read(userInfoProvider.notifier).fetchUser(); ref.read(userInfoProvider.notifier).fetchUser();
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'membershipPurchaseSuccess'.tr()); showSnackBar('membershipPurchaseSuccess'.tr());
} }
} }
} catch (err) { } catch (err) {

View File

@ -1,6 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/annotations.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -51,7 +50,6 @@ Future<List<SnAccountConnection>> accountConnections(Ref ref) async {
.toList(); .toList();
} }
@RoutePage()
class AccountSettingsScreen extends HookConsumerWidget { class AccountSettingsScreen extends HookConsumerWidget {
const AccountSettingsScreen({super.key}); const AccountSettingsScreen({super.key});
@ -72,7 +70,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.delete('/accounts/me'); await client.delete('/accounts/me');
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'accountDeletionSent'.tr()); showSnackBar('accountDeletionSent'.tr());
} }
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
@ -100,7 +98,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
data: {'account': userInfo.value!.name, 'captcha_token': captchaTk}, data: {'account': userInfo.value!.name, 'captcha_token': captchaTk},
); );
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'accountPasswordChangeSent'.tr()); showSnackBar('accountPasswordChangeSent'.tr());
} }
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);

View File

@ -205,7 +205,7 @@ class AuthFactorNewSheet extends HookConsumerWidget {
builder: (context) => AuthFactorNewAdditonalSheet(factor: factor), builder: (context) => AuthFactorNewAdditonalSheet(factor: factor),
).then((_) { ).then((_) {
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); showSnackBar('contactMethodVerificationNeeded'.tr());
} }
if (context.mounted) Navigator.pop(context, true); if (context.mounted) Navigator.pop(context, true);
}); });

View File

@ -181,7 +181,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
}, },
); );
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'accountConnectionAddSuccess'.tr()); showSnackBar('accountConnectionAddSuccess'.tr());
Navigator.pop(context, true); Navigator.pop(context, true);
} }
} catch (err) { } catch (err) {
@ -208,7 +208,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
if (context.mounted) Navigator.pop(context, true); if (context.mounted) Navigator.pop(context, true);
break; break;
default: default:
showSnackBar(context, 'accountConnectionAddError'.tr()); showSnackBar('accountConnectionAddError'.tr());
return; return;
} }
} }

View File

@ -40,7 +40,7 @@ class ContactMethodSheet extends HookConsumerWidget {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.post('/accounts/me/contacts/${contact.id}/verify'); await client.post('/accounts/me/contacts/${contact.id}/verify');
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'contactMethodVerificationSent'.tr()); showSnackBar('contactMethodVerificationSent'.tr());
} }
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
@ -152,7 +152,7 @@ class ContactMethodNewSheet extends HookConsumerWidget {
Future<void> addContactMethod() async { Future<void> addContactMethod() async {
if (contentController.text.isEmpty) { if (contentController.text.isEmpty) {
showSnackBar(context, 'contactMethodContentEmpty'.tr()); showSnackBar('contactMethodContentEmpty'.tr());
return; return;
} }
@ -164,7 +164,7 @@ class ContactMethodNewSheet extends HookConsumerWidget {
data: {'type': contactType.value, 'content': contentController.text}, data: {'type': contactType.value, 'content': contentController.text},
); );
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'contactMethodVerificationNeeded'.tr()); showSnackBar('contactMethodVerificationNeeded'.tr());
Navigator.pop(context, true); Navigator.pop(context, true);
} }
} catch (err) { } catch (err) {

View File

@ -1,4 +1,3 @@
import 'package:auto_route/auto_route.dart';
import 'package:croppy/croppy.dart' hide cropImage; import 'package:croppy/croppy.dart' hide cropImage;
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -20,7 +19,6 @@ import 'package:styled_widget/styled_widget.dart';
const kServerSupportedLanguages = {'en-US': 'en-us', 'zh-CN': 'zh-hans'}; const kServerSupportedLanguages = {'en-US': 'en-us', 'zh-CN': 'zh-hans'};
@RoutePage()
class UpdateProfileScreen extends HookConsumerWidget { class UpdateProfileScreen extends HookConsumerWidget {
const UpdateProfileScreen({super.key}); const UpdateProfileScreen({super.key});

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
@ -96,13 +96,9 @@ Future<SnRelationship?> accountRelationship(Ref ref, String uname) async {
} }
} }
@RoutePage()
class AccountProfileScreen extends HookConsumerWidget { class AccountProfileScreen extends HookConsumerWidget {
final String name; final String name;
const AccountProfileScreen({ const AccountProfileScreen({super.key, required this.name});
super.key,
@PathParam("name") required this.name,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -142,7 +138,7 @@ class AccountProfileScreen extends HookConsumerWidget {
Future<void> directMessageAction() async { Future<void> directMessageAction() async {
if (!account.hasValue) return; if (!account.hasValue) return;
if (accountChat.value != null) { if (accountChat.value != null) {
context.router.pushPath('/chat/${accountChat.value!.id}'); context.push('/chat/${accountChat.value!.id}');
return; return;
} }
showLoadingModal(context); showLoadingModal(context);
@ -153,7 +149,7 @@ class AccountProfileScreen extends HookConsumerWidget {
data: {'related_user_id': account.value!.id}, data: {'related_user_id': account.value!.id},
); );
final chat = SnChatRoom.fromJson(resp.data); final chat = SnChatRoom.fromJson(resp.data);
if (context.mounted) context.router.pushPath('/chat/${chat.id}'); if (context.mounted) context.push('/chat/${chat.id}');
ref.invalidate(accountDirectChatProvider(name)); ref.invalidate(accountDirectChatProvider(name));
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);

View File

@ -1,4 +1,3 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -204,7 +203,6 @@ class RelationshipListTile extends StatelessWidget {
} }
} }
@RoutePage()
class RelationshipScreen extends HookConsumerWidget { class RelationshipScreen extends HookConsumerWidget {
const RelationshipScreen({super.key}); const RelationshipScreen({super.key});
@ -217,6 +215,7 @@ class RelationshipScreen extends HookConsumerWidget {
Future<void> addFriend() async { Future<void> addFriend() async {
final result = await showModalBottomSheet( final result = await showModalBottomSheet(
context: context, context: context,
useRootNavigator: true,
builder: (context) => AccountPickerSheet(), builder: (context) => AccountPickerSheet(),
); );
if (result == null) return; if (result == null) return;
@ -242,12 +241,10 @@ class RelationshipScreen extends HookConsumerWidget {
if (!context.mounted) return; if (!context.mounted) return;
if (isAccept) { if (isAccept) {
showSnackBar( showSnackBar(
context,
'friendRequestAccepted'.tr(args: ['@${relationship.account.name}']), 'friendRequestAccepted'.tr(args: ['@${relationship.account.name}']),
); );
} else { } else {
showSnackBar( showSnackBar(
context,
'friendRequestDeclined'.tr(args: ['@${relationship.account.name}']), 'friendRequestDeclined'.tr(args: ['@${relationship.account.name}']),
); );
} }

View File

@ -1,12 +1,11 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.dart'; import 'package:email_validator/email_validator.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/account/me/update.dart'; import 'package:island/screens/account/me/update.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@ -16,7 +15,6 @@ import 'package:url_launcher/url_launcher_string.dart';
import 'captcha.dart'; import 'captcha.dart';
@RoutePage()
class CreateAccountScreen extends HookConsumerWidget { class CreateAccountScreen extends HookConsumerWidget {
const CreateAccountScreen({super.key}); const CreateAccountScreen({super.key});
@ -307,7 +305,7 @@ class _PostCreateModal extends HookConsumerWidget {
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
context.router.replace(LoginRoute()); context.pushReplacement('/auth/login');
}, },
child: Text('login'.tr()), child: Text('login'.tr()),
), ),

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
import 'package:auto_route/auto_route.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -43,7 +42,6 @@ final Map<int, (String, String, IconData)> kFactorTypes = {
4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm), 4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm),
}; };
@RoutePage()
class LoginScreen extends HookConsumerWidget { class LoginScreen extends HookConsumerWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -427,7 +425,7 @@ class _LoginPickerScreen extends HookConsumerWidget {
onPickFactor(factors!.where((x) => x == factorPicked.value).first); onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext(); onNext();
if (context.mounted) { if (context.mounted) {
showSnackBar(context, err.response!.data.toString()); showSnackBar(err.response!.data.toString());
} }
return; return;
} }

View File

@ -10,6 +10,7 @@ import 'package:gap/gap.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/udid.dart'; import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -204,12 +205,7 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
onPressed: () { onPressed: () {
if (currentUrl != null) { if (currentUrl != null) {
Clipboard.setData(ClipboardData(text: currentUrl!)); Clipboard.setData(ClipboardData(text: currentUrl!));
ScaffoldMessenger.of(context).showSnackBar( showSnackBar('copyToClipboard');
SnackBar(
content: Text('copyToClipboard').tr(),
duration: const Duration(seconds: 1),
),
);
} }
}, },
), ),

View File

@ -1,4 +1,3 @@
import 'package:auto_route/annotations.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@ -14,10 +13,9 @@ import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@RoutePage()
class CallScreen extends HookConsumerWidget { class CallScreen extends HookConsumerWidget {
final String roomId; final String roomId;
const CallScreen({super.key, @PathParam('id') required this.roomId}); const CallScreen({super.key, required this.roomId});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {

View File

@ -1,9 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:croppy/croppy.dart' hide cropImage; import 'package:croppy/croppy.dart' hide cropImage;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -15,7 +15,6 @@ import 'package:island/pods/call.dart';
import 'package:island/pods/chat_summary.dart'; import 'package:island/pods/chat_summary.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
@ -173,9 +172,9 @@ Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async {
.toList(); .toList();
} }
@RoutePage()
class ChatShellScreen extends HookConsumerWidget { class ChatShellScreen extends HookConsumerWidget {
const ChatShellScreen({super.key}); final Widget child;
const ChatShellScreen({super.key, required this.child});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -187,18 +186,17 @@ class ChatShellScreen extends HookConsumerWidget {
child: Row( child: Row(
children: [ children: [
Flexible(flex: 2, child: ChatListScreen(isAside: true)), Flexible(flex: 2, child: ChatListScreen(isAside: true)),
VerticalDivider(width: 1), const VerticalDivider(width: 1),
Flexible(flex: 4, child: AutoRouter()), Flexible(flex: 4, child: child),
], ],
), ),
); );
} }
return AppBackground(isRoot: true, child: AutoRouter()); return AppBackground(isRoot: true, child: child);
} }
} }
@RoutePage()
class ChatListScreen extends HookConsumerWidget { class ChatListScreen extends HookConsumerWidget {
final bool isAside; final bool isAside;
const ChatListScreen({super.key, this.isAside = false}); const ChatListScreen({super.key, this.isAside = false});
@ -229,7 +227,8 @@ class ChatListScreen extends HookConsumerWidget {
Future<void> createDirectMessage() async { Future<void> createDirectMessage() async {
final result = await showModalBottomSheet( final result = await showModalBottomSheet(
context: context, context: context,
builder: (context) => AccountPickerSheet(), useRootNavigator: true,
builder: (context) => const AccountPickerSheet(),
); );
if (result == null) return; if (result == null) return;
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
@ -244,7 +243,7 @@ class ChatListScreen extends HookConsumerWidget {
return AppScaffold( return AppScaffold(
extendBody: false, // Prevent conflicts with tabs navigation extendBody: false, // Prevent conflicts with tabs navigation
appBar: AppBar( appBar: AppBar(
title: Text('chat').tr(), title: const Text('chat').tr(),
bottom: TabBar( bottom: TabBar(
controller: tabController, controller: tabController,
tabs: [ tabs: [
@ -298,7 +297,7 @@ class ChatListScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: (context) => _ChatInvitesSheet(), builder: (context) => const _ChatInvitesSheet(),
); );
}, },
), ),
@ -309,17 +308,18 @@ class ChatListScreen extends HookConsumerWidget {
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
useRootNavigator: true,
builder: builder:
(context) => Column( (context) => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
ListTile( ListTile(
title: Text('createChatRoom').tr(), title: const Text('createChatRoom').tr(),
leading: const Icon(Symbols.add), leading: const Icon(Symbols.add),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
context.pushRoute(NewChatRoute()).then((value) { context.push('/chat/new').then((value) {
if (value != null) { if (value != null) {
ref.invalidate(chatroomsJoinedProvider); ref.invalidate(chatroomsJoinedProvider);
} }
@ -327,7 +327,7 @@ class ChatListScreen extends HookConsumerWidget {
}, },
), ),
ListTile( ListTile(
title: Text('createDirectMessage').tr(), title: const Text('createDirectMessage').tr(),
leading: const Icon(Symbols.person), leading: const Icon(Symbols.person),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
@ -400,16 +400,7 @@ class ChatListScreen extends HookConsumerWidget {
room: item, room: item,
isDirect: item.type == 1, isDirect: item.type == 1,
onTap: () { onTap: () {
if (context.router.topRoute.name == context.push('/chat/${item.id}');
ChatRoomRoute.name) {
context.router.replace(
ChatRoomRoute(id: item.id),
);
} else {
context.router.push(
ChatRoomRoute(id: item.id),
);
}
}, },
); );
}, },
@ -443,33 +434,45 @@ class ChatListScreen extends HookConsumerWidget {
@riverpod @riverpod
Future<SnChatRoom?> chatroom(Ref ref, String? identifier) async { Future<SnChatRoom?> chatroom(Ref ref, String? identifier) async {
if (identifier == null) return null; if (identifier == null) return null;
final client = ref.watch(apiClientProvider); try {
final resp = await client.get('/chat/$identifier'); final client = ref.watch(apiClientProvider);
return SnChatRoom.fromJson(resp.data); final resp = await client.get('/chat/$identifier');
return SnChatRoom.fromJson(resp.data);
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // Chat room not found
}
rethrow; // Rethrow other errors
}
} }
@riverpod @riverpod
Future<SnChatMember?> chatroomIdentity(Ref ref, String? identifier) async { Future<SnChatMember?> chatroomIdentity(Ref ref, String? identifier) async {
if (identifier == null) return null; if (identifier == null) return null;
final client = ref.watch(apiClientProvider); try {
final resp = await client.get('/chat/$identifier/members/me'); final client = ref.watch(apiClientProvider);
return SnChatMember.fromJson(resp.data); final resp = await client.get('/chat/$identifier/members/me');
return SnChatMember.fromJson(resp.data);
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // Chat member not found
}
rethrow; // Rethrow other errors
}
} }
@RoutePage()
class NewChatScreen extends StatelessWidget { class NewChatScreen extends StatelessWidget {
const NewChatScreen({super.key}); const NewChatScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return EditChatScreen(); return const EditChatScreen();
} }
} }
@RoutePage()
class EditChatScreen extends HookConsumerWidget { class EditChatScreen extends HookConsumerWidget {
final String? id; final String? id;
const EditChatScreen({super.key, @PathParam("id") this.id}); const EditChatScreen({super.key, this.id});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -481,6 +484,8 @@ class EditChatScreen extends HookConsumerWidget {
final descriptionController = useTextEditingController(); final descriptionController = useTextEditingController();
final picture = useState<SnCloudFile?>(null); final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null); final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final chat = ref.watch(chatroomProvider(id)); final chat = ref.watch(chatroomProvider(id));
@ -493,12 +498,14 @@ class EditChatScreen extends HookConsumerWidget {
descriptionController.text = chat.value!.description ?? ''; descriptionController.text = chat.value!.description ?? '';
picture.value = chat.value!.picture; picture.value = chat.value!.picture;
background.value = chat.value!.background; background.value = chat.value!.background;
isPublic.value = chat.value!.isPublic;
isCommunity.value = chat.value!.isCommunity;
currentRealm.value = joinedRealms.value?.firstWhereOrNull( currentRealm.value = joinedRealms.value?.firstWhereOrNull(
(realm) => realm.id == chat.value!.realmId, (realm) => realm.id == chat.value!.realmId,
); );
} }
return; return;
}, [chat]); }, [chat, joinedRealms]);
void setPicture(String position) async { void setPicture(String position) async {
showLoadingModal(context); showLoadingModal(context);
@ -516,9 +523,9 @@ class EditChatScreen extends HookConsumerWidget {
image: result, image: result,
allowedAspectRatios: [ allowedAspectRatios: [
if (position == 'background') if (position == 'background')
CropAspectRatio(height: 7, width: 16) const CropAspectRatio(height: 7, width: 16)
else else
CropAspectRatio(height: 1, width: 1), const CropAspectRatio(height: 1, width: 1),
], ],
); );
if (result == null) { if (result == null) {
@ -575,11 +582,13 @@ class EditChatScreen extends HookConsumerWidget {
'background_id': background.value?.id, 'background_id': background.value?.id,
'picture_id': picture.value?.id, 'picture_id': picture.value?.id,
'realm_id': currentRealm.value?.id, 'realm_id': currentRealm.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
}, },
options: Options(method: id == null ? 'POST' : 'PATCH'), options: Options(method: id == null ? 'POST' : 'PATCH'),
); );
if (context.mounted) { if (context.mounted) {
context.maybePop(SnChatRoom.fromJson(resp.data)); context.pop(SnChatRoom.fromJson(resp.data));
} }
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
@ -667,6 +676,19 @@ class EditChatScreen extends HookConsumerWidget {
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
CheckboxListTile(
title: const Text('isPublic').tr(),
subtitle: const Text('isPublicHint').tr(),
value: isPublic.value,
onChanged: (value) => isPublic.value = value ?? false,
),
CheckboxListTile(
title: const Text('isCommunity').tr(),
subtitle: const Text('isCommunityHint').tr(),
value: isCommunity.value,
onChanged: (value) => isCommunity.value = value ?? false,
),
const SizedBox(height: 16),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: TextButton.icon(
@ -767,7 +789,7 @@ class _ChatInvitesSheet extends HookConsumerWidget {
), ),
if (invite.chatRoom!.type == 1) if (invite.chatRoom!.type == 1)
Badge( Badge(
label: Text('directMessage').tr(), label: const Text('directMessage').tr(),
backgroundColor: backgroundColor:
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,
textColor: textColor:

View File

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

View File

@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -18,7 +18,6 @@ import 'package:island/pods/config.dart';
import 'package:island/pods/database.dart'; import 'package:island/pods/database.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.gr.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@ -288,15 +287,76 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
} }
@RoutePage()
class ChatRoomScreen extends HookConsumerWidget { class ChatRoomScreen extends HookConsumerWidget {
final String id; final String id;
const ChatRoomScreen({super.key, @PathParam("id") required this.id}); const ChatRoomScreen({super.key, required this.id});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final chatRoom = ref.watch(chatroomProvider(id)); final chatRoom = ref.watch(chatroomProvider(id));
final chatIdentity = ref.watch(chatroomIdentityProvider(id)); final chatIdentity = ref.watch(chatroomIdentityProvider(id));
if (chatIdentity.isLoading || chatRoom.isLoading) {
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: CircularProgressIndicator().center(),
);
} else if (chatIdentity.value == null) {
// Identity was not found, user was not joined
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: Center(
child:
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
chatRoom.value?.isCommunity == true
? Symbols.person_add
: Symbols.person_remove,
size: 36,
fill: 1,
).padding(bottom: 4),
Text('chatNotJoined').tr(),
if (chatRoom.value?.isCommunity != true)
Text(
'chatUnableJoin',
textAlign: TextAlign.center,
).tr().bold()
else
FilledButton.tonalIcon(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.read(apiClientProvider);
if (chatRoom.value == null) {
hideLoadingModal(context);
return;
}
await apiClient.post(
'/chat/${chatRoom.value!.id}/members/me',
);
ref.invalidate(chatroomIdentityProvider(id));
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
label: Text('chatJoin').tr(),
icon: const Icon(Icons.add),
).padding(top: 8),
],
),
).center(),
),
);
}
final messages = ref.watch(messagesNotifierProvider(id)); final messages = ref.watch(messagesNotifierProvider(id));
final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier); final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier);
final ws = ref.watch(websocketProvider); final ws = ref.watch(websocketProvider);
@ -431,6 +491,28 @@ class ChatRoomScreen extends HookConsumerWidget {
return () => subscription.cancel(); return () => subscription.cancel();
}, [ws, chatRoom]); }, [ws, chatRoom]);
useEffect(() {
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': id},
),
),
);
return () {
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': id},
),
),
);
};
}, [id]);
Future<void> pickPhotoMedia() async { Future<void> pickPhotoMedia() async {
final result = await ref final result = await ref
.watch(imagePickerProvider) .watch(imagePickerProvider)
@ -605,7 +687,7 @@ class ChatRoomScreen extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
onPressed: () { onPressed: () {
context.router.push(ChatDetailRoute(id: id)); context.push('/chat/$id/detail');
}, },
), ),
const Gap(8), const Gap(8),

View File

@ -1,14 +1,13 @@
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/chat.dart';
import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
@ -23,10 +22,9 @@ import 'package:styled_widget/styled_widget.dart';
part 'room_detail.freezed.dart'; part 'room_detail.freezed.dart';
part 'room_detail.g.dart'; part 'room_detail.g.dart';
@RoutePage()
class ChatDetailScreen extends HookConsumerWidget { class ChatDetailScreen extends HookConsumerWidget {
final String id; final String id;
const ChatDetailScreen({super.key, @PathParam("id") required this.id}); const ChatDetailScreen({super.key, required this.id});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -49,7 +47,6 @@ class ChatDetailScreen extends HookConsumerWidget {
ref.invalidate(chatroomIdentityProvider(id)); ref.invalidate(chatroomIdentityProvider(id));
if (context.mounted) { if (context.mounted) {
showSnackBar( showSnackBar(
context,
'chatNotifyLevelUpdated'.tr(args: [kNotifyLevelText[level].tr()]), 'chatNotifyLevelUpdated'.tr(args: [kNotifyLevelText[level].tr()]),
); );
} }
@ -140,7 +137,7 @@ class ChatDetailScreen extends HookConsumerWidget {
setChatBreak(now); setChatBreak(now);
Navigator.pop(context); Navigator.pop(context);
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'chatBreakCleared'.tr()); showSnackBar('chatBreakCleared'.tr());
} }
}, },
), ),
@ -152,7 +149,7 @@ class ChatDetailScreen extends HookConsumerWidget {
setChatBreak(now.add(const Duration(minutes: 5))); setChatBreak(now.add(const Duration(minutes: 5)));
Navigator.pop(context); Navigator.pop(context);
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'chatBreakSet'.tr(args: ['5m'])); showSnackBar('chatBreakSet'.tr(args: ['5m']));
} }
}, },
), ),
@ -164,7 +161,7 @@ class ChatDetailScreen extends HookConsumerWidget {
setChatBreak(now.add(const Duration(minutes: 10))); setChatBreak(now.add(const Duration(minutes: 10)));
Navigator.pop(context); Navigator.pop(context);
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'chatBreakSet'.tr(args: ['10m'])); showSnackBar('chatBreakSet'.tr(args: ['10m']));
} }
}, },
), ),
@ -176,7 +173,7 @@ class ChatDetailScreen extends HookConsumerWidget {
setChatBreak(now.add(const Duration(minutes: 15))); setChatBreak(now.add(const Duration(minutes: 15)));
Navigator.pop(context); Navigator.pop(context);
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'chatBreakSet'.tr(args: ['15m'])); showSnackBar('chatBreakSet'.tr(args: ['15m']));
} }
}, },
), ),
@ -188,7 +185,7 @@ class ChatDetailScreen extends HookConsumerWidget {
setChatBreak(now.add(const Duration(minutes: 30))); setChatBreak(now.add(const Duration(minutes: 30)));
Navigator.pop(context); Navigator.pop(context);
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'chatBreakSet'.tr(args: ['30m'])); showSnackBar('chatBreakSet'.tr(args: ['30m']));
} }
}, },
), ),
@ -208,7 +205,6 @@ class ChatDetailScreen extends HookConsumerWidget {
Navigator.pop(context); Navigator.pop(context);
if (context.mounted) { if (context.mounted) {
showSnackBar( showSnackBar(
context,
'chatBreakSet'.tr(args: ['${minutes}m']), 'chatBreakSet'.tr(args: ['${minutes}m']),
); );
} }
@ -393,7 +389,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
if ((chatIdentity.value?.role ?? 0) >= 50) if ((chatIdentity.value?.role ?? 0) >= 50)
PopupMenuItem( PopupMenuItem(
onTap: () { onTap: () {
context.router.replace(EditChatRoute(id: id)); context.pushReplacement('/chat/$id/edit');
}, },
child: Row( child: Row(
children: [ children: [
@ -428,9 +424,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
client.delete('/chat/$id'); client.delete('/chat/$id');
ref.invalidate(chatroomsJoinedProvider); ref.invalidate(chatroomsJoinedProvider);
if (context.mounted) { if (context.mounted) {
context.router.popUntil( context.pop();
(route) => route is ChatRoomRoute,
);
} }
} }
}); });
@ -463,9 +457,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
client.delete('/chat/$id/members/me'); client.delete('/chat/$id/members/me');
ref.invalidate(chatroomsJoinedProvider); ref.invalidate(chatroomsJoinedProvider);
if (context.mounted) { if (context.mounted) {
context.router.popUntil( context.pop();
(route) => route is ChatRoomRoute,
);
} }
} }
}); });
@ -592,8 +584,8 @@ class _ChatMemberListSheet extends HookConsumerWidget {
Future<void> invitePerson() async { Future<void> invitePerson() async {
final result = await showModalBottomSheet( final result = await showModalBottomSheet(
isScrollControlled: true,
context: context, context: context,
useRootNavigator: true,
builder: (context) => const AccountPickerSheet(), builder: (context) => const AccountPickerSheet(),
); );
if (result == null) return; if (result == null) return;

View File

@ -1,13 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/creators/publishers.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
@ -27,9 +26,9 @@ Future<SnPublisherStats?> publisherStats(Ref ref, String? uname) async {
return SnPublisherStats.fromJson(resp.data); return SnPublisherStats.fromJson(resp.data);
} }
@RoutePage()
class CreatorHubShellScreen extends StatelessWidget { class CreatorHubShellScreen extends StatelessWidget {
const CreatorHubShellScreen({super.key}); final Widget child;
const CreatorHubShellScreen({super.key, required this.child});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -39,15 +38,14 @@ class CreatorHubShellScreen extends StatelessWidget {
children: [ children: [
SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)), SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
Expanded(child: AutoRouter()), Expanded(child: child),
], ],
); );
} }
return AutoRouter(); return child;
} }
} }
@RoutePage()
class CreatorHubScreen extends HookConsumerWidget { class CreatorHubScreen extends HookConsumerWidget {
final bool isAside; final bool isAside;
const CreatorHubScreen({super.key, this.isAside = false}); const CreatorHubScreen({super.key, this.isAside = false});
@ -65,8 +63,8 @@ class CreatorHubScreen extends HookConsumerWidget {
); );
void updatePublisher() { void updatePublisher() {
context.router context
.push(EditPublisherRoute(name: currentPublisher.value!.name)) .push('/creators/${currentPublisher.value!.name}/edit')
.then((value) async { .then((value) async {
if (value == null) return; if (value == null) return;
final data = await ref.refresh(publishersManagedProvider.future); final data = await ref.refresh(publishersManagedProvider.future);
@ -223,7 +221,7 @@ class CreatorHubScreen extends HookConsumerWidget {
subtitle: Text('createPublisherHint').tr(), subtitle: Text('createPublisherHint').tr(),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
context.router.push(NewPublisherRoute()).then(( context.push('/creators/publishers/new').then((
value, value,
) { ) {
if (value != null) { if (value != null) {
@ -249,10 +247,8 @@ class CreatorHubScreen extends HookConsumerWidget {
horizontal: 24, horizontal: 24,
), ),
onTap: () { onTap: () {
context.router.push( context.push(
StickersRoute( '/creators/${currentPublisher.value!.name}/stickers',
pubName: currentPublisher.value!.name,
),
); );
}, },
), ),
@ -265,10 +261,8 @@ class CreatorHubScreen extends HookConsumerWidget {
horizontal: 24, horizontal: 24,
), ),
onTap: () { onTap: () {
context.router.push( context.push(
CreatorPostListRoute( '/creators/${currentPublisher.value!.name}/posts',
pubName: currentPublisher.value!.name,
),
); );
}, },
), ),

View File

@ -1,6 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@ -8,13 +8,9 @@ import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@RoutePage()
class CreatorPostListScreen extends HookConsumerWidget { class CreatorPostListScreen extends HookConsumerWidget {
final String pubName; final String pubName;
const CreatorPostListScreen({ const CreatorPostListScreen({super.key, required this.pubName});
super.key,
@PathParam('name') required this.pubName,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -34,7 +30,7 @@ class CreatorPostListScreen extends HookConsumerWidget {
subtitle: Text('Create a regular post'), subtitle: Text('Create a regular post'),
onTap: () async { onTap: () async {
Navigator.pop(context); Navigator.pop(context);
final result = await context.router.pushPath( final result = await context.push(
'/posts/compose?type=0', '/posts/compose?type=0',
); );
if (result == true) { if (result == true) {
@ -48,7 +44,7 @@ class CreatorPostListScreen extends HookConsumerWidget {
subtitle: Text('Create a detailed article'), subtitle: Text('Create a detailed article'),
onTap: () async { onTap: () async {
Navigator.pop(context); Navigator.pop(context);
final result = await context.router.pushPath( final result = await context.push(
'/posts/compose?type=1', '/posts/compose?type=1',
); );
if (result == true) { if (result == true) {

View File

@ -1,10 +1,10 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:croppy/croppy.dart' hide cropImage; import 'package:croppy/croppy.dart' hide cropImage;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
@ -44,7 +44,6 @@ Future<SnPublisher?> publisher(Ref ref, String? identifier) async {
return SnPublisher.fromJson(resp.data); return SnPublisher.fromJson(resp.data);
} }
@RoutePage()
class NewPublisherScreen extends StatelessWidget { class NewPublisherScreen extends StatelessWidget {
const NewPublisherScreen({super.key}); const NewPublisherScreen({super.key});
@ -54,10 +53,9 @@ class NewPublisherScreen extends StatelessWidget {
} }
} }
@RoutePage()
class EditPublisherScreen extends HookConsumerWidget { class EditPublisherScreen extends HookConsumerWidget {
final String? name; final String? name;
const EditPublisherScreen({super.key, @PathParam('id') this.name}); const EditPublisherScreen({super.key, this.name});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -177,7 +175,7 @@ class EditPublisherScreen extends HookConsumerWidget {
options: Options(method: name == null ? 'POST' : 'PATCH'), options: Options(method: name == null ? 'POST' : 'PATCH'),
); );
if (context.mounted) { if (context.mounted) {
context.maybePop(SnPublisher.fromJson(resp.data)); context.pop(SnPublisher.fromJson(resp.data));
} }
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@ -10,7 +10,6 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/sticker.dart'; import 'package:island/models/sticker.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/creators/stickers/stickers.dart'; import 'package:island/screens/creators/stickers/stickers.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@ -34,14 +33,13 @@ Future<List<SnSticker>> stickerPackContent(Ref ref, String packId) async {
.toList(); .toList();
} }
@RoutePage()
class StickerPackDetailScreen extends HookConsumerWidget { class StickerPackDetailScreen extends HookConsumerWidget {
final String id; final String id;
final String pubName; final String pubName;
const StickerPackDetailScreen({ const StickerPackDetailScreen({
super.key, super.key,
@PathParam('name') required this.pubName, required this.pubName,
@PathParam('packId') required this.id, required this.id,
}); });
@override @override
@ -76,7 +74,7 @@ class StickerPackDetailScreen extends HookConsumerWidget {
IconButton( IconButton(
icon: const Icon(Symbols.add_circle), icon: const Icon(Symbols.add_circle),
onPressed: () { onPressed: () {
AutoRouter.of(context).push(NewStickersRoute(packId: id)).then(( context.push('/creators/stickers/$id/new').then((
value, value,
) { ) {
if (value != null) { if (value != null) {
@ -175,12 +173,9 @@ class StickerPackDetailScreen extends HookConsumerWidget {
title: 'edit'.tr(), title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit), image: MenuImage.icon(Symbols.edit),
callback: () { callback: () {
context.router context
.push( .push(
EditStickersRoute( '/creators/stickers/$id/edit/${sticker.id}',
packId: id,
id: sticker.id,
),
) )
.then((value) { .then((value) {
if (value != null) { if (value != null) {
@ -264,8 +259,8 @@ class _StickerPackActionMenu extends HookConsumerWidget {
(context) => [ (context) => [
PopupMenuItem( PopupMenuItem(
onTap: () { onTap: () {
context.router.push( context.push(
EditStickerPacksRoute(pubName: pubName, packId: packId), '/creators/$pubName/stickers/$packId/edit',
); );
}, },
child: Row( child: Row(
@ -299,7 +294,7 @@ class _StickerPackActionMenu extends HookConsumerWidget {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
client.delete('/stickers/$packId'); client.delete('/stickers/$packId');
ref.invalidate(stickerPacksNotifierProvider); ref.invalidate(stickerPacksNotifierProvider);
if (context.mounted) context.router.maybePop(true); if (context.mounted) context.pop(true);
} }
}); });
}, },
@ -331,13 +326,9 @@ Future<SnSticker?> stickerPackSticker(
return SnSticker.fromJson(resp.data); return SnSticker.fromJson(resp.data);
} }
@RoutePage()
class NewStickersScreen extends StatelessWidget { class NewStickersScreen extends StatelessWidget {
final String packId; final String packId;
const NewStickersScreen({ const NewStickersScreen({super.key, required this.packId});
super.key,
@PathParam('packId') required this.packId,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -345,15 +336,10 @@ class NewStickersScreen extends StatelessWidget {
} }
} }
@RoutePage()
class EditStickersScreen extends HookConsumerWidget { class EditStickersScreen extends HookConsumerWidget {
final String packId; final String packId;
final String? id; final String? id;
const EditStickersScreen({ const EditStickersScreen({super.key, required this.packId, required this.id});
super.key,
@PathParam("packId") required this.packId,
@PathParam("id") required this.id,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {

View File

@ -1,13 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/sticker.dart'; import 'package:island/models/sticker.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@ -17,10 +16,9 @@ import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
part 'stickers.g.dart'; part 'stickers.g.dart';
@RoutePage()
class StickersScreen extends HookConsumerWidget { class StickersScreen extends HookConsumerWidget {
final String pubName; final String pubName;
const StickersScreen({super.key, @PathParam("name") required this.pubName}); const StickersScreen({super.key, required this.pubName});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -30,7 +28,7 @@ class StickersScreen extends HookConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
onPressed: () { onPressed: () {
context.router.push(NewStickerPacksRoute(pubName: pubName)).then(( context.push('/creators/stickers/new?pubName=pubName').then((
value, value,
) { ) {
if (value != null) { if (value != null) {
@ -73,8 +71,8 @@ class SliverStickerPacksList extends HookConsumerWidget {
subtitle: Text(sticker.description), subtitle: Text(sticker.description),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
context.router.push( context.push(
StickerPackDetailRoute(pubName: pubName, id: sticker.id), '/creators/$pubName/stickers/${sticker.id}',
); );
}, },
); );
@ -137,13 +135,9 @@ Future<SnStickerPack?> stickerPack(Ref ref, String? packId) async {
return SnStickerPack.fromJson(resp.data); return SnStickerPack.fromJson(resp.data);
} }
@RoutePage()
class NewStickerPacksScreen extends HookConsumerWidget { class NewStickerPacksScreen extends HookConsumerWidget {
final String pubName; final String pubName;
const NewStickerPacksScreen({ const NewStickerPacksScreen({super.key, required this.pubName});
super.key,
@PathParam("name") required this.pubName,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -151,15 +145,10 @@ class NewStickerPacksScreen extends HookConsumerWidget {
} }
} }
@RoutePage()
class EditStickerPacksScreen extends HookConsumerWidget { class EditStickerPacksScreen extends HookConsumerWidget {
final String pubName; final String pubName;
final String? packId; final String? packId;
const EditStickerPacksScreen({ const EditStickerPacksScreen({super.key, required this.pubName, this.packId});
super.key,
@PathParam("name") required this.pubName,
@PathParam("packId") this.packId,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -200,7 +189,7 @@ class EditStickerPacksScreen extends HookConsumerWidget {
), ),
); );
if (!context.mounted) return; if (!context.mounted) return;
context.router.maybePop(SnStickerPack.fromJson(resp.data)); context.pop(SnStickerPack.fromJson(resp.data));
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {

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,34 +1,36 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/activity.dart'; import 'package:island/models/activity.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/route.gr.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart'; import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/tour/tour.dart';
import 'package:island/screens/tabs.dart'; import 'package:island/screens/tabs.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/widgets/realm/realm_card.dart';
import 'package:island/widgets/publisher/publisher_card.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
part 'explore.g.dart'; part 'explore.g.dart';
@RoutePage() class ExploreShellScreen extends HookConsumerWidget {
class ExploreShellScreen extends ConsumerWidget { final Widget child;
const ExploreShellScreen({super.key}); const ExploreShellScreen({super.key, required this.child});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context); final isWide = MediaQuery.of(context).size.width > 640;
if (isWide) { if (isWide) {
return AppBackground( return AppBackground(
@ -37,17 +39,16 @@ class ExploreShellScreen extends ConsumerWidget {
children: [ children: [
Flexible(flex: 2, child: ExploreScreen(isAside: true)), Flexible(flex: 2, child: ExploreScreen(isAside: true)),
VerticalDivider(width: 1), VerticalDivider(width: 1),
Flexible(flex: 3, child: AutoRouter()), Flexible(flex: 3, child: child),
], ],
), ),
); );
} }
return AppBackground(isRoot: true, child: AutoRouter()); return AppBackground(isRoot: true, child: child);
} }
} }
@RoutePage()
class ExploreScreen extends HookConsumerWidget { class ExploreScreen extends HookConsumerWidget {
final bool isAside; final bool isAside;
const ExploreScreen({super.key, this.isAside = false}); const ExploreScreen({super.key, this.isAside = false});
@ -85,65 +86,64 @@ class ExploreScreen extends HookConsumerWidget {
activityListNotifierProvider(currentFilter.value).notifier, activityListNotifierProvider(currentFilter.value).notifier,
); );
return TourTriggerWidget( return AppScaffold(
child: AppScaffold( extendBody: false, // Prevent conflicts with tabs navigation
extendBody: false, // Prevent conflicts with tabs navigation appBar: AppBar(
appBar: AppBar( toolbarHeight: 0,
toolbarHeight: 0, bottom: TabBar(
bottom: TabBar(
controller: tabController,
tabs: [
Tab(
child: Text(
'explore'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'exploreFilterSubscriptions'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'exploreFilterFriends'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
],
),
),
floatingActionButton: FloatingActionButton(
heroTag: Key("explore-page-fab"),
onPressed: () {
context.router.push(PostComposeRoute()).then((value) {
if (value != null) {
activitiesNotifier.forceRefresh();
}
});
},
child: const Icon(Symbols.edit),
),
floatingActionButtonLocation: TabbedFabLocation(context),
body: TabBarView(
controller: tabController, controller: tabController,
children: [ tabs: [
_buildActivityList(ref, null), Tab(
_buildActivityList(ref, 'subscriptions'), child: Text(
_buildActivityList(ref, 'friends'), 'explore'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'exploreFilterSubscriptions'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'exploreFilterFriends'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
], ],
), ),
), ),
floatingActionButton: FloatingActionButton(
heroTag: Key("explore-page-fab"),
onPressed: () {
context.push('/posts/compose').then((value) {
if (value != null) {
activitiesNotifier.forceRefresh();
}
});
},
child: const Icon(Symbols.edit),
),
floatingActionButtonLocation: TabbedFabLocation(context),
body: TabBarView(
controller: tabController,
physics: const NeverScrollableScrollPhysics(),
children: [
_buildActivityList(ref, null),
_buildActivityList(ref, 'subscriptions'),
_buildActivityList(ref, 'friends'),
],
),
); );
} }
@ -173,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 { class _ActivityListView extends HookConsumerWidget {
final CursorPagingData<SnActivity> data; final CursorPagingData<SnActivity> data;
final int widgetCount; final int widgetCount;
@ -216,10 +274,14 @@ class _ActivityListView extends HookConsumerWidget {
itemWidget = PostItem( itemWidget = PostItem(
backgroundColor: backgroundColor:
isWideScreen(context) ? Colors.transparent : null, isWideScreen(context) ? Colors.transparent : null,
item: SnPost.fromJson(item.data), item: SnPost.fromJson(item.data!),
padding: padding:
isReply isReply
? EdgeInsets.only(left: 16, right: 16, bottom: 16) ? const EdgeInsets.only(
left: 16,
right: 16,
bottom: 16,
)
: null, : null,
onRefresh: (_) { onRefresh: (_) {
activitiesNotifier.forceRefresh(); activitiesNotifier.forceRefresh();
@ -247,6 +309,9 @@ class _ActivityListView extends HookConsumerWidget {
); );
} }
break; break;
case 'discovery':
itemWidget = _DiscoveryActivityItem(data: item.data!);
break;
default: default:
itemWidget = const Placeholder(); itemWidget = const Placeholder();
} }
@ -276,6 +341,7 @@ class ActivityListNotifier extends _$ActivityListNotifier
if (cursor != null) 'cursor': cursor, if (cursor != null) 'cursor': cursor,
'take': take, 'take': take,
if (filter != null) 'filter': filter, if (filter != null) 'filter': filter,
if (kDebugMode) 'debugInclude': 'realms,publishers',
}; };
final response = await client.get( final response = await client.get(

View File

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

View File

@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/user.dart'; import 'package:island/models/user.dart';
@ -107,7 +107,6 @@ class NotificationListNotifier extends _$NotificationListNotifier
} }
} }
@RoutePage()
class NotificationScreen extends HookConsumerWidget { class NotificationScreen extends HookConsumerWidget {
const NotificationScreen({super.key}); const NotificationScreen({super.key});
@ -186,7 +185,6 @@ class NotificationScreen extends HookConsumerWidget {
final uri = Uri.tryParse(href); final uri = Uri.tryParse(href);
if (uri == null) { if (uri == null) {
showSnackBar( showSnackBar(
context,
'brokenLink'.tr(args: []), 'brokenLink'.tr(args: []),
action: SnackBarAction( action: SnackBarAction(
label: 'copyToClipboard'.tr(), label: 'copyToClipboard'.tr(),
@ -199,7 +197,7 @@ class NotificationScreen extends HookConsumerWidget {
return; return;
} }
if (uri.scheme == 'solian') { if (uri.scheme == 'solian') {
context.router.pushPath( context.push(
['', uri.host, ...uri.pathSegments].join('/'), ['', uri.host, ...uri.pathSegments].join('/'),
); );
return; return;

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
@ -22,10 +22,26 @@ import 'package:island/widgets/post/draft_manager.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@RoutePage() part 'compose.freezed.dart';
part 'compose.g.dart';
@freezed
sealed class PostComposeInitialState with _$PostComposeInitialState {
const factory PostComposeInitialState({
String? title,
String? description,
String? content,
@Default([]) List<UniversalFile> attachments,
int? visibility,
}) = _PostComposeInitialState;
factory PostComposeInitialState.fromJson(Map<String, dynamic> json) =>
_$PostComposeInitialStateFromJson(json);
}
class PostEditScreen extends HookConsumerWidget { class PostEditScreen extends HookConsumerWidget {
final String id; final String id;
const PostEditScreen({super.key, @PathParam('id') required this.id}); const PostEditScreen({super.key, required this.id});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -48,18 +64,19 @@ class PostEditScreen extends HookConsumerWidget {
} }
} }
@RoutePage()
class PostComposeScreen extends HookConsumerWidget { class PostComposeScreen extends HookConsumerWidget {
final SnPost? originalPost; final SnPost? originalPost;
final SnPost? repliedPost; final SnPost? repliedPost;
final SnPost? forwardedPost; final SnPost? forwardedPost;
final int? type; final int? type;
final PostComposeInitialState? initialState;
const PostComposeScreen({ const PostComposeScreen({
super.key, super.key,
this.originalPost, this.originalPost,
this.repliedPost, this.repliedPost,
this.forwardedPost, this.forwardedPost,
@QueryParam('type') this.type, this.type,
this.initialState,
}); });
@override @override
@ -86,15 +103,32 @@ class PostComposeScreen extends HookConsumerWidget {
originalPost: originalPost, originalPost: originalPost,
forwardedPost: effectiveForwardedPost, forwardedPost: effectiveForwardedPost,
repliedPost: effectiveRepliedPost, repliedPost: effectiveRepliedPost,
postType: 0, // Regular post type
), ),
[originalPost, effectiveForwardedPost, effectiveRepliedPost], [originalPost, effectiveForwardedPost, effectiveRepliedPost],
); );
// Add a listener to the entire state to trigger rebuilds
final stateNotifier = useMemoized(
() => Listenable.merge([
state.titleController,
state.descriptionController,
state.contentController,
state.visibility,
state.attachments,
state.attachmentProgress,
state.currentPublisher,
state.submitting,
]),
[state],
);
useListenable(stateNotifier);
// Start auto-save when component mounts // Start auto-save when component mounts
useEffect(() { useEffect(() {
if (originalPost == null) { if (originalPost == null) {
// Only auto-save for new posts, not edits // Only auto-save for new posts, not edits
state.startAutoSave(ref, postType: 0); state.startAutoSave(ref);
} }
return () => state.stopAutoSave(); return () => state.stopAutoSave();
}, [state]); }, [state]);
@ -107,22 +141,44 @@ class PostComposeScreen extends HookConsumerWidget {
return null; return null;
}, [publishers]); }, [publishers]);
// Load draft if available (only for new posts) // Load initial state if provided (for sharing functionality)
useEffect(() {
if (initialState != null) {
state.titleController.text = initialState!.title ?? '';
state.descriptionController.text = initialState!.description ?? '';
state.contentController.text = initialState!.content ?? '';
if (initialState!.visibility != null) {
state.visibility.value = initialState!.visibility!;
}
if (initialState!.attachments.isNotEmpty) {
state.attachments.value = List.from(initialState!.attachments);
}
}
return null;
}, [initialState]);
// Load draft if available (only for new posts without initial state)
useEffect(() { useEffect(() {
if (originalPost == null && if (originalPost == null &&
effectiveForwardedPost == null && effectiveForwardedPost == null &&
effectiveRepliedPost == null) { effectiveRepliedPost == null &&
initialState == null) {
// Try to load the most recent draft // Try to load the most recent draft
final drafts = ref.read(composeStorageNotifierProvider); final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) { if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce( final mostRecentDraft = drafts.values.reduce(
(a, b) => (a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0)) ? a : b, (a, b) =>
(a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0))
? a
: b,
); );
// Only load if the draft has meaningful content // Only load if the draft has meaningful content
if (mostRecentDraft.content?.isNotEmpty == true || mostRecentDraft.title?.isNotEmpty == true) { if (mostRecentDraft.content?.isNotEmpty == true ||
mostRecentDraft.title?.isNotEmpty == true) {
state.titleController.text = mostRecentDraft.title ?? ''; state.titleController.text = mostRecentDraft.title ?? '';
state.descriptionController.text = mostRecentDraft.description ?? ''; state.descriptionController.text =
mostRecentDraft.description ?? '';
state.contentController.text = mostRecentDraft.content ?? ''; state.contentController.text = mostRecentDraft.content ?? '';
state.visibility.value = mostRecentDraft.visibility; state.visibility.value = mostRecentDraft.visibility;
} }
@ -150,6 +206,8 @@ class PostComposeScreen extends HookConsumerWidget {
titleController: state.titleController, titleController: state.titleController,
descriptionController: state.descriptionController, descriptionController: state.descriptionController,
visibility: state.visibility, visibility: state.visibility,
tagsController: state.tagsController,
categoriesController: state.categoriesController,
onVisibilityChanged: () { onVisibilityChanged: () {
// Trigger rebuild if needed // Trigger rebuild if needed
}, },
@ -169,22 +227,18 @@ class PostComposeScreen extends HookConsumerWidget {
), ),
itemCount: state.attachments.value.length, itemCount: state.attachments.value.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return ValueListenableBuilder<Map<int, double>>( final progressMap = state.attachmentProgress.value;
valueListenable: state.attachmentProgress, return AttachmentPreview(
builder: (context, progressMap, _) { item: state.attachments.value[idx],
return AttachmentPreview( progress: progressMap[idx],
item: state.attachments.value[idx], onRequestUpload:
progress: progressMap[idx], () => ComposeLogic.uploadAttachment(ref, state, idx),
onRequestUpload: onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
() => ComposeLogic.uploadAttachment(ref, state, idx), onMove: (delta) {
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), state.attachments.value = ComposeLogic.moveAttachment(
onMove: (delta) { state.attachments.value,
state.attachments.value = ComposeLogic.moveAttachment( idx,
state.attachments.value, delta,
idx,
delta,
);
},
); );
}, },
); );
@ -198,26 +252,24 @@ class PostComposeScreen extends HookConsumerWidget {
for (var idx = 0; idx < state.attachments.value.length; idx++) for (var idx = 0; idx < state.attachments.value.length; idx++)
Container( Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ValueListenableBuilder<Map<int, double>>( child: () {
valueListenable: state.attachmentProgress, final progressMap = state.attachmentProgress.value;
builder: (context, progressMap, _) { return AttachmentPreview(
return AttachmentPreview( item: state.attachments.value[idx],
item: state.attachments.value[idx], progress: progressMap[idx],
progress: progressMap[idx], onRequestUpload:
onRequestUpload: () => ComposeLogic.uploadAttachment(ref, state, idx),
() => ComposeLogic.uploadAttachment(ref, state, idx), onDelete:
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
() => ComposeLogic.deleteAttachment(ref, state, idx), onMove: (delta) {
onMove: (delta) { state.attachments.value = ComposeLogic.moveAttachment(
state.attachments.value = ComposeLogic.moveAttachment( state.attachments.value,
state.attachments.value, idx,
idx, delta,
delta, );
); },
}, );
); }(),
},
),
), ),
], ],
); );
@ -253,7 +305,8 @@ class PostComposeScreen extends HookConsumerWidget {
state.titleController.text = draft.title ?? ''; state.titleController.text = draft.title ?? '';
state.descriptionController.text = state.descriptionController.text =
draft.description ?? ''; draft.description ?? '';
state.contentController.text = draft.content ?? ''; state.contentController.text =
draft.content ?? '';
state.visibility.value = draft.visibility; state.visibility.value = draft.visibility;
} }
}, },
@ -272,39 +325,31 @@ class PostComposeScreen extends HookConsumerWidget {
onPressed: showSettingsSheet, onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(), tooltip: 'postSettings'.tr(),
), ),
ValueListenableBuilder<bool>( IconButton(
valueListenable: state.submitting, onPressed:
builder: (context, submitting, _) { state.submitting.value
return IconButton( ? null
onPressed: : () => ComposeLogic.performAction(
submitting ref,
? null state,
: () => ComposeLogic.performAction( context,
ref, originalPost: originalPost,
state, repliedPost: repliedPost,
context, forwardedPost: forwardedPost,
originalPost: originalPost, ),
repliedPost: repliedPost, icon:
forwardedPost: forwardedPost, state.submitting.value
postType: 0, // Regular post type ? SizedBox(
), width: 28,
icon: height: 28,
submitting child: const CircularProgressIndicator(
? SizedBox( color: Colors.white,
width: 28, strokeWidth: 2.5,
height: 28, ),
child: const CircularProgressIndicator( ).center()
color: Colors.white, : Icon(
strokeWidth: 2.5, originalPost != null ? Symbols.edit : Symbols.upload,
), ),
).center()
: Icon(
originalPost != null
? Symbols.edit
: Symbols.upload,
),
);
},
), ),
const Gap(8), const Gap(8),
], ],
@ -365,7 +410,6 @@ class PostComposeScreen extends HookConsumerWidget {
originalPost: originalPost, originalPost: originalPost,
repliedPost: repliedPost, repliedPost: repliedPost,
forwardedPost: forwardedPost, forwardedPost: forwardedPost,
postType: 0, // Regular post type
), ),
child: TextField( child: TextField(
controller: state.contentController, controller: state.contentController,
@ -386,22 +430,17 @@ class PostComposeScreen extends HookConsumerWidget {
const Gap(8), const Gap(8),
// Attachments preview // Attachments preview
ValueListenableBuilder<List<UniversalFile>>( if (state.attachments.value.isNotEmpty)
valueListenable: state.attachments, LayoutBuilder(
builder: (context, attachments, _) { builder: (context, constraints) {
if (attachments.isEmpty) { final isWide = isWideScreen(context);
return const SizedBox.shrink(); return isWide
} ? buildWideAttachmentGrid()
return LayoutBuilder( : buildNarrowAttachmentList();
builder: (context, constraints) { },
final isWide = isWideScreen(context); )
return isWide else
? buildWideAttachmentGrid() const SizedBox.shrink(),
: buildNarrowAttachmentList();
},
);
},
),
], ],
), ),
), ),

View File

@ -0,0 +1,166 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'compose.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PostComposeInitialState {
String? get title; String? get description; String? get content; List<UniversalFile> get attachments; int? get visibility;
/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PostComposeInitialStateCopyWith<PostComposeInitialState> get copyWith => _$PostComposeInitialStateCopyWithImpl<PostComposeInitialState>(this as PostComposeInitialState, _$identity);
/// Serializes this PostComposeInitialState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(attachments),visibility);
@override
String toString() {
return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)';
}
}
/// @nodoc
abstract mixin class $PostComposeInitialStateCopyWith<$Res> {
factory $PostComposeInitialStateCopyWith(PostComposeInitialState value, $Res Function(PostComposeInitialState) _then) = _$PostComposeInitialStateCopyWithImpl;
@useResult
$Res call({
String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility
});
}
/// @nodoc
class _$PostComposeInitialStateCopyWithImpl<$Res>
implements $PostComposeInitialStateCopyWith<$Res> {
_$PostComposeInitialStateCopyWithImpl(this._self, this._then);
final PostComposeInitialState _self;
final $Res Function(PostComposeInitialState) _then;
/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) {
return _then(_self.copyWith(
title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable
as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// @nodoc
@JsonSerializable()
class _PostComposeInitialState implements PostComposeInitialState {
const _PostComposeInitialState({this.title, this.description, this.content, final List<UniversalFile> attachments = const [], this.visibility}): _attachments = attachments;
factory _PostComposeInitialState.fromJson(Map<String, dynamic> json) => _$PostComposeInitialStateFromJson(json);
@override final String? title;
@override final String? description;
@override final String? content;
final List<UniversalFile> _attachments;
@override@JsonKey() List<UniversalFile> get attachments {
if (_attachments is EqualUnmodifiableListView) return _attachments;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_attachments);
}
@override final int? visibility;
/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$PostComposeInitialStateCopyWith<_PostComposeInitialState> get copyWith => __$PostComposeInitialStateCopyWithImpl<_PostComposeInitialState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$PostComposeInitialStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(_attachments),visibility);
@override
String toString() {
return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)';
}
}
/// @nodoc
abstract mixin class _$PostComposeInitialStateCopyWith<$Res> implements $PostComposeInitialStateCopyWith<$Res> {
factory _$PostComposeInitialStateCopyWith(_PostComposeInitialState value, $Res Function(_PostComposeInitialState) _then) = __$PostComposeInitialStateCopyWithImpl;
@override @useResult
$Res call({
String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility
});
}
/// @nodoc
class __$PostComposeInitialStateCopyWithImpl<$Res>
implements _$PostComposeInitialStateCopyWith<$Res> {
__$PostComposeInitialStateCopyWithImpl(this._self, this._then);
final _PostComposeInitialState _self;
final $Res Function(_PostComposeInitialState) _then;
/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) {
return _then(_PostComposeInitialState(
title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable
as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
// dart format on

View File

@ -0,0 +1,31 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'compose.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_PostComposeInitialState _$PostComposeInitialStateFromJson(
Map<String, dynamic> json,
) => _PostComposeInitialState(
title: json['title'] as String?,
description: json['description'] as String?,
content: json['content'] as String?,
attachments:
(json['attachments'] as List<dynamic>?)
?.map((e) => UniversalFile.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
visibility: (json['visibility'] as num?)?.toInt(),
);
Map<String, dynamic> _$PostComposeInitialStateToJson(
_PostComposeInitialState instance,
) => <String, dynamic>{
'title': instance.title,
'description': instance.description,
'content': instance.content,
'attachments': instance.attachments.map((e) => e.toJson()).toList(),
'visibility': instance.visibility,
};

View File

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -26,10 +25,9 @@ import 'package:island/widgets/post/draft_manager.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@RoutePage()
class ArticleEditScreen extends HookConsumerWidget { class ArticleEditScreen extends HookConsumerWidget {
final String id; final String id;
const ArticleEditScreen({super.key, @PathParam('id') required this.id}); const ArticleEditScreen({super.key, required this.id});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -50,7 +48,6 @@ class ArticleEditScreen extends HookConsumerWidget {
} }
} }
@RoutePage()
class ArticleComposeScreen extends HookConsumerWidget { class ArticleComposeScreen extends HookConsumerWidget {
final SnPost? originalPost; final SnPost? originalPost;
@ -63,7 +60,10 @@ class ArticleComposeScreen extends HookConsumerWidget {
final publishers = ref.watch(publishersManagedProvider); final publishers = ref.watch(publishersManagedProvider);
final state = useMemoized( final state = useMemoized(
() => ComposeLogic.createState(originalPost: originalPost), () => ComposeLogic.createState(
originalPost: originalPost,
postType: 1, // Article type
),
[originalPost], [originalPost],
); );
@ -73,7 +73,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
if (originalPost == null) { if (originalPost == null) {
// Only auto-save for new articles, not edits // Only auto-save for new articles, not edits
autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) { autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); ComposeLogic.saveDraftWithoutUpload(ref, state);
}); });
} }
return () { return () {
@ -81,7 +81,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
state.stopAutoSave(); state.stopAutoSave();
// Save final draft before disposing // Save final draft before disposing
if (originalPost == null) { if (originalPost == null) {
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); ComposeLogic.saveDraftWithoutUpload(ref, state);
} }
ComposeLogic.dispose(state); ComposeLogic.dispose(state);
autoSaveTimer?.cancel(); autoSaveTimer?.cancel();
@ -143,6 +143,8 @@ class ArticleComposeScreen extends HookConsumerWidget {
titleController: state.titleController, titleController: state.titleController,
descriptionController: state.descriptionController, descriptionController: state.descriptionController,
visibility: state.visibility, visibility: state.visibility,
tagsController: state.tagsController,
categoriesController: state.categoriesController,
onVisibilityChanged: () { onVisibilityChanged: () {
// Trigger rebuild if needed // Trigger rebuild if needed
}, },
@ -363,7 +365,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
return PopScope( return PopScope(
onPopInvoked: (_) { onPopInvoked: (_) {
if (originalPost == null) { if (originalPost == null) {
ComposeLogic.saveDraftWithoutUpload(ref, state, postType: 1); ComposeLogic.saveDraftWithoutUpload(ref, state);
} }
}, },
child: AppScaffold( child: AppScaffold(
@ -411,7 +413,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
), ),
IconButton( IconButton(
icon: const Icon(Symbols.save), icon: const Icon(Symbols.save),
onPressed: () => ComposeLogic.saveDraft(ref, state, postType: 1), onPressed: () => ComposeLogic.saveDraft(ref, state),
tooltip: 'saveDraft'.tr(), tooltip: 'saveDraft'.tr(),
), ),
IconButton( IconButton(
@ -438,7 +440,6 @@ class ArticleComposeScreen extends HookConsumerWidget {
state, state,
context, context,
originalPost: originalPost, originalPost: originalPost,
postType: 1, // Article type
), ),
icon: icon:
submitting submitting
@ -531,18 +532,17 @@ class ArticleComposeScreen extends HookConsumerWidget {
if (isPaste && isModifierPressed) { if (isPaste && isModifierPressed) {
ComposeLogic.handlePaste(state); ComposeLogic.handlePaste(state);
} else if (isSave && isModifierPressed) { } else if (isSave && isModifierPressed) {
ComposeLogic.saveDraft(ref, state, postType: 1); ComposeLogic.saveDraft(ref, state);
ComposeLogic.saveDraft(ref, state);
} else if (isSubmit && isModifierPressed && !state.submitting.value) { } else if (isSubmit && isModifierPressed && !state.submitting.value) {
ComposeLogic.performAction( ComposeLogic.performAction(
ref, ref,
state, state,
context, context,
originalPost: originalPost, originalPost: originalPost,
postType: 1, // Article type
); );
} }
} }
// Helper method to save article draft // Helper method to save article draft
} }

View File

@ -1,4 +1,3 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -22,10 +21,9 @@ Future<SnPost?> post(Ref ref, String id) async {
return SnPost.fromJson(resp.data); return SnPost.fromJson(resp.data);
} }
@RoutePage()
class PostDetailScreen extends HookConsumerWidget { class PostDetailScreen extends HookConsumerWidget {
final String id; final String id;
const PostDetailScreen({super.key, @PathParam('id') required this.id}); const PostDetailScreen({super.key, required this.id});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {

View File

@ -1,6 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -54,26 +54,26 @@ Future<SnSubscriptionStatus> publisherSubscriptionStatus(
@riverpod @riverpod
Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async { Future<Color?> publisherAppbarForcegroundColor(Ref ref, String pubName) async {
final publisher = await ref.watch(publisherProvider(pubName).future); try {
if (publisher.background == null) return null; final publisher = await ref.watch(publisherProvider(pubName).future);
final palette = await PaletteGenerator.fromImageProvider( if (publisher.background == null) return null;
CloudImageWidget.provider( final palette = await PaletteGenerator.fromImageProvider(
fileId: publisher.background!.id, CloudImageWidget.provider(
serverUrl: ref.watch(serverUrlProvider), fileId: publisher.background!.id,
), serverUrl: ref.watch(serverUrlProvider),
); ),
final dominantColor = palette.dominantColor?.color; );
if (dominantColor == null) return null; final dominantColor = palette.dominantColor?.color;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; if (dominantColor == null) return null;
return dominantColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
} catch (_) {
return null;
}
} }
@RoutePage()
class PublisherProfileScreen extends HookConsumerWidget { class PublisherProfileScreen extends HookConsumerWidget {
final String name; final String name;
const PublisherProfileScreen({ const PublisherProfileScreen({super.key, required this.name});
super.key,
@PathParam("name") required this.name,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -186,7 +186,7 @@ class PublisherProfileScreen extends HookConsumerWidget {
), ),
onTap: () { onTap: () {
Navigator.pop(context, true); Navigator.pop(context, true);
context.router.pushPath('/account/${data.name}'); context.push('/account/${data.name}');
}, },
), ),
Expanded( Expanded(

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:croppy/croppy.dart' show CropAspectRatio; import 'package:croppy/croppy.dart' show CropAspectRatio;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -11,7 +11,6 @@ import 'package:island/models/file.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
@ -33,7 +32,6 @@ Future<List<SnRealm>> realmsJoined(Ref ref) async {
return resp.data.map((e) => SnRealm.fromJson(e)).cast<SnRealm>().toList(); return resp.data.map((e) => SnRealm.fromJson(e)).cast<SnRealm>().toList();
} }
@RoutePage()
class RealmListScreen extends HookConsumerWidget { class RealmListScreen extends HookConsumerWidget {
const RealmListScreen({super.key}); const RealmListScreen({super.key});
@ -48,6 +46,10 @@ class RealmListScreen extends HookConsumerWidget {
appBar: AppBar( appBar: AppBar(
title: const Text('realms').tr(), title: const Text('realms').tr(),
actions: [ actions: [
IconButton(
icon: const Icon(Symbols.travel_explore),
onPressed: () => context.push('/discovery/realms'),
),
IconButton( IconButton(
icon: Badge( icon: Badge(
label: Text( label: Text(
@ -68,7 +70,7 @@ class RealmListScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => _RealmInviteSheet(), builder: (_) => const _RealmInviteSheet(),
); );
}, },
), ),
@ -76,10 +78,10 @@ class RealmListScreen extends HookConsumerWidget {
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
heroTag: Key("realms-page-fab"), heroTag: const Key("realms-page-fab"),
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
onPressed: () { onPressed: () {
context.router.push(NewRealmRoute()).then((value) { context.push('/realms/new').then((value) {
if (value != null) { if (value != null) {
ref.invalidate(realmsJoinedProvider); ref.invalidate(realmsJoinedProvider);
} }
@ -106,11 +108,9 @@ class RealmListScreen extends HookConsumerWidget {
title: Text(value[item].name), title: Text(value[item].name),
subtitle: Text(value[item].description), subtitle: Text(value[item].description),
onTap: () { onTap: () {
context.router.push( context.push('/realms/${value[item].slug}');
RealmDetailRoute(slug: value[item].slug),
);
}, },
contentPadding: EdgeInsets.only( contentPadding: const EdgeInsets.only(
left: 16, left: 16,
right: 14, right: 14,
top: 8, top: 8,
@ -143,7 +143,6 @@ Future<SnRealm?> realm(Ref ref, String? identifier) async {
return SnRealm.fromJson(resp.data); return SnRealm.fromJson(resp.data);
} }
@RoutePage()
class NewRealmScreen extends StatelessWidget { class NewRealmScreen extends StatelessWidget {
const NewRealmScreen({super.key}); const NewRealmScreen({super.key});
@ -153,10 +152,9 @@ class NewRealmScreen extends StatelessWidget {
} }
} }
@RoutePage()
class EditRealmScreen extends HookConsumerWidget { class EditRealmScreen extends HookConsumerWidget {
final String? slug; final String? slug;
const EditRealmScreen({super.key, @PathParam('slug') this.slug}); const EditRealmScreen({super.key, this.slug});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -164,6 +162,8 @@ class EditRealmScreen extends HookConsumerWidget {
final picture = useState<SnCloudFile?>(null); final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null); final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final slugController = useTextEditingController(); final slugController = useTextEditingController();
final nameController = useTextEditingController(); final nameController = useTextEditingController();
@ -180,6 +180,8 @@ class EditRealmScreen extends HookConsumerWidget {
slugController.text = realm.value!.slug; slugController.text = realm.value!.slug;
nameController.text = realm.value!.name; nameController.text = realm.value!.name;
descriptionController.text = realm.value!.description; descriptionController.text = realm.value!.description;
isPublic.value = realm.value!.isPublic;
isCommunity.value = realm.value!.isCommunity;
} }
return null; return null;
}, [realm]); }, [realm]);
@ -200,9 +202,9 @@ class EditRealmScreen extends HookConsumerWidget {
image: result, image: result,
allowedAspectRatios: [ allowedAspectRatios: [
if (position == 'background') if (position == 'background')
CropAspectRatio(height: 7, width: 16) const CropAspectRatio(height: 7, width: 16)
else else
CropAspectRatio(height: 1, width: 1), const CropAspectRatio(height: 1, width: 1),
], ],
); );
if (result == null) { if (result == null) {
@ -258,11 +260,13 @@ class EditRealmScreen extends HookConsumerWidget {
'description': descriptionController.text, 'description': descriptionController.text,
'background_id': background.value?.id, 'background_id': background.value?.id,
'picture_id': picture.value?.id, 'picture_id': picture.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
}, },
options: Options(method: slug == null ? 'POST' : 'PATCH'), options: Options(method: slug == null ? 'POST' : 'PATCH'),
); );
if (context.mounted) { if (context.mounted) {
context.maybePop(SnRealm.fromJson(resp.data)); context.pop(SnRealm.fromJson(resp.data));
} }
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
@ -290,9 +294,9 @@ class EditRealmScreen extends HookConsumerWidget {
child: child:
background.value != null background.value != null
? CloudFileWidget( ? CloudFileWidget(
item: background.value!, item: background.value!,
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
onTap: () { onTap: () {
@ -320,7 +324,6 @@ class EditRealmScreen extends HookConsumerWidget {
key: formKey, key: formKey,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [ children: [
TextFormField( TextFormField(
controller: slugController, controller: slugController,
@ -331,12 +334,14 @@ class EditRealmScreen extends HookConsumerWidget {
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
const SizedBox(height: 16),
TextFormField( TextFormField(
controller: nameController, controller: nameController,
decoration: InputDecoration(labelText: 'name'.tr()), decoration: InputDecoration(labelText: 'name'.tr()),
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
const SizedBox(height: 16),
TextFormField( TextFormField(
controller: descriptionController, controller: descriptionController,
decoration: InputDecoration(labelText: 'description'.tr()), decoration: InputDecoration(labelText: 'description'.tr()),
@ -345,6 +350,20 @@ class EditRealmScreen extends HookConsumerWidget {
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
const SizedBox(height: 16),
CheckboxListTile(
title: const Text('isPublic').tr(),
subtitle: const Text('isPublicHint').tr(),
value: isPublic.value,
onChanged: (value) => isPublic.value = value ?? false,
),
CheckboxListTile(
title: const Text('isCommunity').tr(),
subtitle: const Text('isCommunityHint').tr(),
value: isCommunity.value,
onChanged: (value) => isCommunity.value = value ?? false,
),
const SizedBox(height: 16),
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: TextButton.icon(
@ -416,47 +435,47 @@ class _RealmInviteSheet extends HookConsumerWidget {
(items) => (items) =>
items.isEmpty items.isEmpty
? Center( ? Center(
child: child:
Text( Text(
'invitesEmpty', 'invitesEmpty',
textAlign: TextAlign.center, textAlign: TextAlign.center,
).tr(), ).tr(),
) )
: ListView.builder( : ListView.builder(
shrinkWrap: true, shrinkWrap: true,
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final invite = items[index]; final invite = items[index];
return ListTile( return ListTile(
leading: ProfilePictureWidget( leading: ProfilePictureWidget(
fileId: invite.realm!.picture?.id, fileId: invite.realm!.picture?.id,
fallbackIcon: Symbols.group, fallbackIcon: Symbols.group,
), ),
title: Text(invite.realm!.name), title: Text(invite.realm!.name),
subtitle: subtitle:
Text( Text(
invite.role >= 100 invite.role >= 100
? 'permissionOwner' ? 'permissionOwner'
: invite.role >= 50 : invite.role >= 50
? 'permissionModerator' ? 'permissionModerator'
: 'permissionMember', : 'permissionMember',
).tr(), ).tr(),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Symbols.check), icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite), onPressed: () => acceptInvite(invite),
), ),
IconButton( IconButton(
icon: const Icon(Symbols.close), icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite), onPressed: () => declineInvite(invite),
), ),
], ],
), ),
); );
}, },
), ),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error:
(error, _) => ResponseErrorWidget( (error, _) => ResponseErrorWidget(
@ -466,4 +485,4 @@ class _RealmInviteSheet extends HookConsumerWidget {
), ),
); );
} }
} }

View File

@ -1,11 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@ -21,7 +21,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
@RoutePage()
class SettingsScreen extends HookConsumerWidget { class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -110,7 +109,7 @@ class SettingsScreen extends HookConsumerWidget {
ref ref
.read(appSettingsNotifierProvider.notifier) .read(appSettingsNotifierProvider.notifier)
.setCustomFonts(null); .setCustomFonts(null);
showSnackBar(context, 'settingsApplied'.tr()); showSnackBar('settingsApplied'.tr());
}, },
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
@ -122,7 +121,7 @@ class SettingsScreen extends HookConsumerWidget {
ref ref
.read(appSettingsNotifierProvider.notifier) .read(appSettingsNotifierProvider.notifier)
.setCustomFonts(value.isEmpty ? null : value); .setCustomFonts(value.isEmpty ? null : value);
showSnackBar(context, 'settingsApplied'.tr()); showSnackBar('settingsApplied'.tr());
}, },
), ),
), ),
@ -215,7 +214,7 @@ class SettingsScreen extends HookConsumerWidget {
prefs.setBool(kAppBackgroundStoreKey, true); prefs.setBool(kAppBackgroundStoreKey, true);
ref.invalidate(backgroundImageFileProvider); ref.invalidate(backgroundImageFileProvider);
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'settingsApplied'.tr()); showSnackBar('settingsApplied'.tr());
} }
}, },
), ),
@ -243,7 +242,7 @@ class SettingsScreen extends HookConsumerWidget {
prefs.remove(kAppBackgroundStoreKey); prefs.remove(kAppBackgroundStoreKey);
ref.invalidate(backgroundImageFileProvider); ref.invalidate(backgroundImageFileProvider);
if (context.mounted) { if (context.mounted) {
showSnackBar(context, 'settingsApplied'.tr()); showSnackBar('settingsApplied'.tr());
} }
}, },
); );
@ -290,7 +289,7 @@ class SettingsScreen extends HookConsumerWidget {
.setAppColorScheme(color.value); .setAppColorScheme(color.value);
if (context.mounted) { if (context.mounted) {
hideLoadingModal(context); hideLoadingModal(context);
showSnackBar(context, 'settingsApplied'.tr()); showSnackBar('settingsApplied'.tr());
} }
}, },
); );
@ -321,7 +320,7 @@ class SettingsScreen extends HookConsumerWidget {
kNetworkServerDefault, kNetworkServerDefault,
); );
ref.invalidate(serverUrlProvider); ref.invalidate(serverUrlProvider);
showSnackBar(context, 'settingsApplied'.tr()); showSnackBar('settingsApplied'.tr());
}, },
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
@ -333,7 +332,7 @@ class SettingsScreen extends HookConsumerWidget {
if (value.isNotEmpty) { if (value.isNotEmpty) {
prefs.setString(kNetworkServerStoreKey, value); prefs.setString(kNetworkServerStoreKey, value);
ref.invalidate(serverUrlProvider); ref.invalidate(serverUrlProvider);
showSnackBar(context, 'settingsApplied'.tr()); showSnackBar('settingsApplied'.tr());
} }
}, },
), ),
@ -590,7 +589,7 @@ class SettingsScreen extends HookConsumerWidget {
if (isDesktop && if (isDesktop &&
event is KeyDownEvent && event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) { event.logicalKey == LogicalKeyboardKey.escape) {
context.router.pop(); context.pop();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
return KeyEventResult.ignored; return KeyEventResult.ignored;

View File

@ -1,20 +1,32 @@
import 'dart:ui'; import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/route.gr.dart'; import 'package:go_router/go_router.dart';
import 'package:island/screens/notification.dart'; import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/navigation/conditional_bottom_nav.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@RoutePage() final currentRouteProvider = StateProvider<String?>((ref) => null);
class TabsScreen extends HookConsumerWidget { class TabsScreen extends HookConsumerWidget {
const TabsScreen({super.key}); final Widget? child;
const TabsScreen({super.key, this.child});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final useHorizontalLayout = isWideScreen(context); // final useHorizontalLayout = isWideScreen(context);
final currentLocation = GoRouterState.of(context).uri.toString();
// Update the current route provider whenever the location changes
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(currentRouteProvider.notifier).state = currentLocation;
});
return null;
}, [currentLocation]);
final notificationUnreadCount = ref.watch( final notificationUnreadCount = ref.watch(
notificationUnreadCountNotifierProvider, notificationUnreadCountNotifierProvider,
@ -40,83 +52,89 @@ class TabsScreen extends HookConsumerWidget {
), ),
]; ];
final routes = <PageRouteInfo>[ final routes = [
ExploreRoute(), '/',
ChatListRoute(), '/chat',
RealmListRoute(), '/realms',
AccountRoute(), '/account',
]; ];
return AutoTabsRouter.tabBar( int getCurrentIndex() {
routes: routes, if (currentLocation.startsWith('/chat')) return 1;
scrollDirection: useHorizontalLayout ? Axis.vertical : Axis.horizontal, if (currentLocation.startsWith('/realms')) return 2;
physics: const NeverScrollableScrollPhysics(), if (currentLocation.startsWith('/account')) return 3;
builder: (context, child, _) { return 0; // Default to explore
final tabsRouter = AutoTabsRouter.of(context); }
if (isWideScreen(context)) { void onDestinationSelected(int index) {
return Row( context.go(routes[index]);
children: [ }
NavigationRail(
destinations:
destinations
.map(
(e) => NavigationRailDestination(
icon: e.icon,
label: Text(e.label),
),
)
.toList(),
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: tabsRouter.setActiveIndex,
),
const VerticalDivider(width: 1),
Expanded(child: child),
],
);
}
return Stack( final currentIndex = getCurrentIndex();
children: [
Positioned.fill(child: child), if (isWideScreen(context)) {
Positioned( return Row(
left: 0, children: [
right: 0, NavigationRail(
bottom: 0, destinations:
child: ClipRRect( destinations
child: BackdropFilter( .map(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), (e) => NavigationRailDestination(
child: Container( icon: e.icon,
decoration: BoxDecoration( label: Text(e.label),
color: Theme.of(
context,
).colorScheme.surface.withOpacity(0.8),
),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: NavigationBar(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
overlayColor: WidgetStatePropertyAll(
Colors.transparent,
),
surfaceTintColor: Colors.transparent,
height: 56,
labelBehavior:
NavigationDestinationLabelBehavior.alwaysHide,
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: tabsRouter.setActiveIndex,
destinations: destinations,
), ),
)
.toList(),
selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected,
),
const VerticalDivider(width: 1),
Expanded(child: child ?? const SizedBox.shrink()),
],
);
}
return Stack(
children: [
Positioned.fill(child: child ?? const SizedBox.shrink()),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ConditionalBottomNav(
child: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface.withOpacity(0.8),
),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: NavigationBar(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
overlayColor: const WidgetStatePropertyAll(
Colors.transparent,
),
surfaceTintColor: Colors.transparent,
height: 56,
labelBehavior:
NavigationDestinationLabelBehavior.alwaysHide,
selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
), ),
), ),
), ),
), ),
), ),
], ),
); ),
}, ],
); );
} }
} }

View File

@ -1,4 +1,3 @@
import 'package:auto_route/annotations.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -72,7 +71,6 @@ class TransactionListNotifier extends _$TransactionListNotifier
} }
} }
@RoutePage()
class WalletScreen extends HookConsumerWidget { class WalletScreen extends HookConsumerWidget {
const WalletScreen({super.key}); const WalletScreen({super.key});

View File

@ -185,7 +185,6 @@ Completer<SnCloudFile?> _processUpload(
onProgress: (double progress, Duration estimate) { onProgress: (double progress, Duration estimate) {
onProgress?.call(progress, estimate); onProgress?.call(progress, estimate);
}, },
measureUploadSpeed: true,
) )
.catchError(completer.completeError); .catchError(completer.completeError);

View File

@ -1,9 +1,67 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:island/main.dart';
import 'package:island/route.dart';
import 'package:island/models/user.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/widgets/app_notification.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart';
import 'package:url_launcher/url_launcher_string.dart';
StreamSubscription<WebSocketPacket> setupNotificationListener(
BuildContext context,
WidgetRef ref,
) {
final ws = ref.watch(websocketProvider);
return ws.dataStream.listen((pkt) {
if (pkt.type == "notifications.new") {
final notification = SnNotification.fromJson(pkt.data!);
showTopSnackBar(
globalOverlay.currentState!,
NotificationCard(notification: notification),
onTap: () {
if (notification.meta['action_uri'] != null) {
var uri = notification.meta['action_uri'] as String;
if (uri.startsWith('/')) {
// In-app routes
rootNavigatorKey.currentContext?.push(
notification.meta['action_uri'],
);
} else {
// External URLs
launchUrlString(uri);
}
}
},
onDismissed: () {},
dismissType: DismissType.onSwipe,
displayDuration: const Duration(seconds: 5),
snackBarPosition: SnackBarPosition.top,
padding: EdgeInsets.only(
left: 16,
right: 16,
top:
(!kIsWeb &&
(Platform.isMacOS ||
Platform.isWindows ||
Platform.isLinux))
? 24
// ignore: use_build_context_synchronously
: MediaQuery.of(context).padding.top + 8,
bottom: 16,
),
);
}
});
}
Future<void> subscribePushNotification(Dio apiClient) async { Future<void> subscribePushNotification(Dio apiClient) async {
await FirebaseMessaging.instance.requestPermission( await FirebaseMessaging.instance.requestPermission(

View File

@ -1,9 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:island/widgets/share/share_sheet.dart'; import 'package:island/widgets/share/share_sheet.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:easy_localization/easy_localization.dart';
class SharingIntentService { class SharingIntentService {
static final SharingIntentService _instance = static final SharingIntentService _instance =
@ -16,6 +17,7 @@ class SharingIntentService {
/// Initialize the sharing intent service /// Initialize the sharing intent service
void initialize(BuildContext context) { void initialize(BuildContext context) {
if (kIsWeb || !(Platform.isIOS || Platform.isAndroid)) return;
debugPrint("SharingIntentService: Initializing with context"); debugPrint("SharingIntentService: Initializing with context");
_context = context; _context = context;
_setupSharingListeners(); _setupSharingListeners();
@ -73,14 +75,44 @@ class SharingIntentService {
); );
} }
// Convert SharedMediaFile to XFile // Convert SharedMediaFile to XFile for files
final List<XFile> files = final List<XFile> files =
sharedFiles sharedFiles
.where(
(file) =>
file.type == SharedMediaType.file ||
file.type == SharedMediaType.video ||
file.type == SharedMediaType.image,
)
.map((file) => XFile(file.path, name: file.path.split('/').last)) .map((file) => XFile(file.path, name: file.path.split('/').last))
.toList(); .toList();
// Extract links from shared content
final List<String> links =
sharedFiles
.where((file) => file.type == SharedMediaType.url)
.map((file) => file.path)
.toList();
// Show ShareSheet with the shared files // Show ShareSheet with the shared files
showShareSheet(context: _context!, content: ShareContent.files(files)); if (files.isNotEmpty) {
showShareSheet(context: _context!, content: ShareContent.files(files));
} else if (links.isNotEmpty) {
showShareSheet(
context: _context!,
content: ShareContent.link(links.first),
);
} else {
showShareSheet(
context: _context!,
content: ShareContent.text(
sharedFiles
.where((file) => file.type == SharedMediaType.text)
.map((text) => text.message)
.join('\n'),
),
);
}
} }
/// Dispose of resources /// Dispose of resources

View File

@ -1,8 +1,8 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_popup_card/flutter_popup_card.dart'; import 'package:flutter_popup_card/flutter_popup_card.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -105,7 +105,7 @@ class AccountProfileCard extends HookConsumerWidget {
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: () { onPressed: () {
Navigator.pop(context); Navigator.pop(context);
context.router.pushPath('/account/${data.name}'); context.push('/account/${data.name}');
}, },
icon: const Icon(Symbols.launch), icon: const Icon(Symbols.launch),
label: Text('accountProfileView').tr(), label: Text('accountProfileView').tr(),

View File

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/activity.dart'; import 'package:island/models/activity.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -66,7 +66,7 @@ class FortuneGraphWidget extends HookConsumerWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
onPressed: () { onPressed: () {
context.router.pushNamed( context.pushNamed(
'/account/$eventCalanderUser/calendar', '/account/$eventCalanderUser/calendar',
); );
}, },

View File

@ -38,7 +38,7 @@ class RestorePurchaseSheet extends HookConsumerWidget {
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); Navigator.pop(context);
showSnackBar(context, 'Purchase restored successfully!'); showSnackBar('Purchase restored successfully!');
} }
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);

View File

@ -1,31 +1,18 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/services/responsive.dart'; import 'package:island/main.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart';
export 'content/alert.native.dart' export 'content/alert.native.dart'
if (dart.library.html) 'content/alert.web.dart'; if (dart.library.html) 'content/alert.web.dart';
void showSnackBar( void showSnackBar(String message, {SnackBarAction? action}) {
BuildContext context, showTopSnackBar(
String message, { globalOverlay.currentState!,
SnackBarAction? action, Card(child: Text(message).padding(horizontal: 20, vertical: 16)),
}) { snackBarPosition: SnackBarPosition.bottom,
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
action: action,
margin:
isWideScreen(context)
? null
: EdgeInsets.fromLTRB(
15.0,
5.0,
15.0,
MediaQuery.of(context).padding.bottom + 28,
),
),
); );
} }

View File

@ -1,235 +1,18 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/main.dart';
import 'package:island/models/user.dart'; import 'package:island/models/user.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
part 'app_notification.freezed.dart'; class NotificationCard extends HookConsumerWidget {
part 'app_notification.g.dart'; final SnNotification notification;
class AppNotificationToast extends HookConsumerWidget { const NotificationCard({super.key, required this.notification});
const AppNotificationToast({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final notifications = ref.watch(appNotificationsProvider); final icon = Symbols.info;
// Create a global key for AnimatedList
final listKey = useMemoized(() => GlobalKey<AnimatedListState>());
// Track visual notification count (including those being animated out)
final visualCount = useState(notifications.length);
// Track notifications being removed to manage visual count
final animatingOutIds = useState<Set<String>>({});
// Track previous notifications to detect changes
final previousNotifications = usePrevious(notifications) ?? [];
// Handle notification changes
useEffect(() {
final currentIds = notifications.map((n) => n.data.id).toSet();
final previousIds = previousNotifications.map((n) => n.data.id).toSet();
// Find new notifications (added)
final newIds = currentIds.difference(previousIds);
// Update visual count for new notifications
if (newIds.isNotEmpty) {
visualCount.value += newIds.length;
}
// Insert new notifications with animation
for (final id in newIds) {
final index = notifications.indexWhere((n) => n.data.id == id);
if (index != -1 &&
listKey.currentState != null &&
index >= 0 &&
index <= notifications.length) {
try {
listKey.currentState!.insertItem(
index,
duration: const Duration(milliseconds: 150),
);
} catch (e) {
// Log error but don't crash the app
debugPrint('Error inserting notification: $e');
}
}
}
return null;
}, [notifications]);
return Positioned(
top: MediaQuery.of(context).padding.top + 50,
left: 16,
right: 16,
child: SizedBox(
// Use visualCount instead of notifications.length for height calculation
height: visualCount.value * 80,
child: AnimatedList(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
key: listKey,
initialItemCount: notifications.length,
itemBuilder: (context, index, animation) {
// Safely access notifications with bounds check
if (index >= notifications.length) {
return const SizedBox.shrink(); // Return empty widget if out of bounds
}
final notification = notifications[index];
final now = DateTime.now();
final createdAt = notification.createdAt ?? now;
final duration =
notification.duration ?? const Duration(seconds: 5);
final elapsedTime = now.difference(createdAt);
final remainingTime = duration - elapsedTime;
final progress =
1.0 -
(remainingTime.inMilliseconds / duration.inMilliseconds).clamp(
0.0,
1.0,
); // Ensure progress is clamped
return SizeTransition(
sizeFactor: animation.drive(
CurveTween(curve: Curves.fastLinearToSlowEaseIn),
),
child: _NotificationCard(
notification: notification,
progress: progress.clamp(0.0, 1.0),
onDismiss: () {
// Find the current index before removal
final currentIndex = notifications.indexWhere(
(n) => n.data.id == notification.data.id,
);
// Add to animating out set
final notificationId = notification.data.id;
if (!animatingOutIds.value.contains(notificationId)) {
animatingOutIds.value = {
...animatingOutIds.value,
notificationId,
};
}
if (currentIndex != -1 &&
listKey.currentState != null &&
currentIndex >= 0 &&
currentIndex < notifications.length) {
try {
// Remove the item with animation
listKey.currentState!.removeItem(
currentIndex,
(context, animation) => SizeTransition(
sizeFactor: animation.drive(
CurveTween(curve: Curves.fastLinearToSlowEaseIn),
),
child: _NotificationCard(
notification: notification,
progress: progress.clamp(0.0, 1.0),
onDismiss:
() {}, // Empty because it's being removed
),
),
duration: const Duration(milliseconds: 150),
// When animation completes, update the visual count
);
// Schedule decrementing the visual count after animation completes
Future.delayed(const Duration(milliseconds: 150), () {
if (animatingOutIds.value.contains(notificationId)) {
visualCount.value =
visualCount.value > 0 ? visualCount.value - 1 : 0;
animatingOutIds.value =
animatingOutIds.value
.where((id) => id != notificationId)
.toSet();
}
});
} catch (e) {
// Log error but don't crash the app
log('[Notification] Error removing notification: $e');
// Still update visual count in case of error
visualCount.value =
visualCount.value > 0 ? visualCount.value - 1 : 0;
animatingOutIds.value =
animatingOutIds.value
.where((id) => id != notificationId)
.toSet();
}
}
// Actually remove from state
ref
.read(appNotificationsProvider.notifier)
.removeNotification(notification);
},
),
);
},
),
),
);
}
}
class _NotificationCard extends HookConsumerWidget {
final AppNotification notification;
final double progress;
final VoidCallback onDismiss;
const _NotificationCard({
required this.notification,
required this.progress,
required this.onDismiss,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Use state to track the current progress for smooth animation
final progressState = useState(progress);
// Use effect to update progress smoothly
useEffect(() {
if (progress < 1.0) {
// Update progress every 16ms (roughly 60fps) for smooth animation
final timer = Timer.periodic(const Duration(milliseconds: 16), (_) {
final now = DateTime.now();
final createdAt = notification.createdAt ?? now;
final duration = notification.duration ?? const Duration(seconds: 5);
final elapsedTime = now.difference(createdAt);
final remainingTime = duration - elapsedTime;
final newProgress = (1.0 -
(remainingTime.inMilliseconds / duration.inMilliseconds))
.clamp(0.0, 1.0);
progressState.value = newProgress;
// Auto-dismiss when complete
if (newProgress >= 1.0) {
onDismiss();
}
});
return timer.cancel;
}
return null;
}, [notification.createdAt, notification.duration]);
return Card( return Card(
elevation: 4, elevation: 4,
@ -237,225 +20,52 @@ class _NotificationCard extends HookConsumerWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)), borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)),
), ),
child: InkWell( child: Column(
borderRadius: const BorderRadius.all(Radius.circular(8)), crossAxisAlignment: CrossAxisAlignment.start,
onTap: () { mainAxisSize: MainAxisSize.min,
if (notification.data.meta['action_uri'] != null) { children: [
var uri = notification.data.meta['action_uri'] as String; Padding(
if (uri.startsWith('/')) { padding: const EdgeInsets.all(12),
// In-app routes child: Row(
appRouter.pushPath(notification.data.meta['action_uri']); crossAxisAlignment: CrossAxisAlignment.start,
} else { children: [
// External URLs if (notification.meta['pfp'] != null)
launchUrlString(uri); ProfilePictureWidget(
} fileId: notification.meta['pfp'],
onDismiss(); radius: 12,
} ).padding(right: 12, top: 2)
}, else
child: Column( Icon(
crossAxisAlignment: CrossAxisAlignment.start, icon,
mainAxisSize: MainAxisSize.min, color: Theme.of(context).colorScheme.primary,
children: [ size: 24,
// Progress indicator ).padding(right: 12),
if (progressState.value > 0 && progressState.value < 1.0) Expanded(
AnimatedBuilder( child: Column(
animation: progressState, crossAxisAlignment: CrossAxisAlignment.start,
builder: (context, _) { children: [
return LinearProgressIndicator( Text(
borderRadius: BorderRadius.vertical( notification.title,
top: Radius.circular(16), style: Theme.of(context).textTheme.titleMedium
), ?.copyWith(fontWeight: FontWeight.bold),
value: 1.0 - progressState.value, ),
backgroundColor: Colors.transparent, if (notification.content.isNotEmpty)
color: Theme.of(context).colorScheme.tertiary,
minHeight: 3,
stopIndicatorColor: Colors.transparent,
stopIndicatorRadius: 0,
);
},
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (notification.data.meta['avatar'] != null)
ProfilePictureWidget(
fileId: notification.data.meta['avatar'],
radius: 12,
).padding(right: 12, top: 2)
else if (notification.icon != null)
Icon(
notification.icon,
color: Theme.of(context).colorScheme.primary,
size: 24,
).padding(right: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text( Text(
notification.data.title, notification.content,
style: Theme.of(context).textTheme.titleMedium style: Theme.of(context).textTheme.bodyMedium,
?.copyWith(fontWeight: FontWeight.bold),
), ),
if (notification.data.content.isNotEmpty) if (notification.subtitle.isNotEmpty)
Text( Text(
notification.data.content, notification.subtitle,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodySmall,
), ),
if (notification.data.subtitle.isNotEmpty) ],
Text(
notification.data.subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
), ),
IconButton( ),
icon: const Icon(Symbols.close, size: 18), ],
onPressed: onDismiss,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
), ),
], ),
), ],
),
);
}
}
@freezed
sealed class AppNotification with _$AppNotification {
const factory AppNotification({
required SnNotification data,
@JsonKey(ignore: true) IconData? icon,
@JsonKey(ignore: true) Duration? duration,
@Default(null) DateTime? createdAt,
@Default(false) @JsonKey(ignore: true) bool isAnimatingOut,
}) = _AppNotification;
factory AppNotification.fromJson(Map<String, dynamic> json) =>
_$AppNotificationFromJson(json);
}
// Using riverpod_generator for cleaner provider code
@riverpod
class AppNotifications extends _$AppNotifications {
StreamSubscription? _subscription;
@override
List<AppNotification> build() {
ref.onDispose(() {
_subscription?.cancel();
});
_initWebSocketListener();
return [];
}
void _initWebSocketListener() {
final service = ref.read(websocketProvider);
_subscription = service.dataStream.listen((packet) {
// Handle notification packets
if (packet.type == 'notifications.new') {
try {
final data = SnNotification.fromJson(packet.data!);
IconData? icon;
switch (data.topic) {
case 'general':
default:
icon = Symbols.info;
break;
}
addNotification(
AppNotification(
data: data,
icon: icon,
createdAt: data.createdAt.toLocal(),
duration: const Duration(seconds: 5),
),
);
} catch (e) {
log('[Notification] Error processing notification: $e');
}
}
});
}
void addNotification(AppNotification notification) {
// Create a new notification with createdAt if not provided
final newNotification =
notification.createdAt == null
? notification.copyWith(createdAt: DateTime.now())
: notification;
// Add to state
state = [...state, newNotification];
// Auto-remove notification after duration
final duration = newNotification.duration ?? const Duration(seconds: 5);
Future.delayed(duration, () {
// Find the notification in the current state
final notificationToRemove = state.firstWhereOrNull(
(n) => n.data.id == newNotification.data.id,
);
// Only proceed if the notification still exists in state
if (notificationToRemove != null) {
// Call removeNotification which will handle the animation
removeNotification(notificationToRemove);
}
});
}
// Map to track notifications that are being animated out
final Map<String, bool> _animatingNotifications = {};
// Map to track which notifications should animate out
final Map<String, bool> _animatingOutNotifications = {};
void removeNotification(AppNotification notification) {
final notificationId = notification.data.id;
// If this notification is already being removed, don't do anything
if (_animatingNotifications[notificationId] == true) {
return;
}
// Mark this notification as being removed
_animatingNotifications[notificationId] = true;
// Remove from state immediately - AnimatedList handles the animation
state = state.where((n) => n.data.id != notificationId).toList();
// Clean up tracking
_animatingNotifications.remove(notificationId);
_animatingOutNotifications.remove(notificationId);
}
// Helper method to check if a notification should animate out
bool isAnimatingOut(String notificationId) {
return _animatingOutNotifications[notificationId] == true;
}
// Helper method to manually add a notification for testing
void showNotification({
required SnNotification data,
IconData? icon,
Duration? duration,
}) {
addNotification(
AppNotification(
data: data,
icon: icon,
duration: duration,
createdAt: data.createdAt,
), ),
); );
} }

View File

@ -1,190 +0,0 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'app_notification.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AppNotification implements DiagnosticableTreeMixin {
SnNotification get data;@JsonKey(ignore: true) IconData? get icon;@JsonKey(ignore: true) Duration? get duration; DateTime? get createdAt;@JsonKey(ignore: true) bool get isAnimatingOut;
/// Create a copy of AppNotification
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AppNotificationCopyWith<AppNotification> get copyWith => _$AppNotificationCopyWithImpl<AppNotification>(this as AppNotification, _$identity);
/// Serializes this AppNotification to a JSON map.
Map<String, dynamic> toJson();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'AppNotification'))
..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)';
}
}
/// @nodoc
abstract mixin class $AppNotificationCopyWith<$Res> {
factory $AppNotificationCopyWith(AppNotification value, $Res Function(AppNotification) _then) = _$AppNotificationCopyWithImpl;
@useResult
$Res call({
SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut
});
$SnNotificationCopyWith<$Res> get data;
}
/// @nodoc
class _$AppNotificationCopyWithImpl<$Res>
implements $AppNotificationCopyWith<$Res> {
_$AppNotificationCopyWithImpl(this._self, this._then);
final AppNotification _self;
final $Res Function(AppNotification) _then;
/// Create a copy of AppNotification
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) {
return _then(_self.copyWith(
data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable
as bool,
));
}
/// Create a copy of AppNotification
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnNotificationCopyWith<$Res> get data {
return $SnNotificationCopyWith<$Res>(_self.data, (value) {
return _then(_self.copyWith(data: value));
});
}
}
/// @nodoc
@JsonSerializable()
class _AppNotification with DiagnosticableTreeMixin implements AppNotification {
const _AppNotification({required this.data, @JsonKey(ignore: true) this.icon, @JsonKey(ignore: true) this.duration, this.createdAt = null, @JsonKey(ignore: true) this.isAnimatingOut = false});
factory _AppNotification.fromJson(Map<String, dynamic> json) => _$AppNotificationFromJson(json);
@override final SnNotification data;
@override@JsonKey(ignore: true) final IconData? icon;
@override@JsonKey(ignore: true) final Duration? duration;
@override@JsonKey() final DateTime? createdAt;
@override@JsonKey(ignore: true) final bool isAnimatingOut;
/// Create a copy of AppNotification
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$AppNotificationCopyWith<_AppNotification> get copyWith => __$AppNotificationCopyWithImpl<_AppNotification>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$AppNotificationToJson(this, );
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'AppNotification'))
..add(DiagnosticsProperty('data', data))..add(DiagnosticsProperty('icon', icon))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('createdAt', createdAt))..add(DiagnosticsProperty('isAnimatingOut', isAnimatingOut));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppNotification&&(identical(other.data, data) || other.data == data)&&(identical(other.icon, icon) || other.icon == icon)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isAnimatingOut, isAnimatingOut) || other.isAnimatingOut == isAnimatingOut));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,data,icon,duration,createdAt,isAnimatingOut);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'AppNotification(data: $data, icon: $icon, duration: $duration, createdAt: $createdAt, isAnimatingOut: $isAnimatingOut)';
}
}
/// @nodoc
abstract mixin class _$AppNotificationCopyWith<$Res> implements $AppNotificationCopyWith<$Res> {
factory _$AppNotificationCopyWith(_AppNotification value, $Res Function(_AppNotification) _then) = __$AppNotificationCopyWithImpl;
@override @useResult
$Res call({
SnNotification data,@JsonKey(ignore: true) IconData? icon,@JsonKey(ignore: true) Duration? duration, DateTime? createdAt,@JsonKey(ignore: true) bool isAnimatingOut
});
@override $SnNotificationCopyWith<$Res> get data;
}
/// @nodoc
class __$AppNotificationCopyWithImpl<$Res>
implements _$AppNotificationCopyWith<$Res> {
__$AppNotificationCopyWithImpl(this._self, this._then);
final _AppNotification _self;
final $Res Function(_AppNotification) _then;
/// Create a copy of AppNotification
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? data = null,Object? icon = freezed,Object? duration = freezed,Object? createdAt = freezed,Object? isAnimatingOut = null,}) {
return _then(_AppNotification(
data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as SnNotification,icon: freezed == icon ? _self.icon : icon // ignore: cast_nullable_to_non_nullable
as IconData?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration?,createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,isAnimatingOut: null == isAnimatingOut ? _self.isAnimatingOut : isAnimatingOut // ignore: cast_nullable_to_non_nullable
as bool,
));
}
/// Create a copy of AppNotification
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnNotificationCopyWith<$Res> get data {
return $SnNotificationCopyWith<$Res>(_self.data, (value) {
return _then(_self.copyWith(data: value));
});
}
}
// dart format on

View File

@ -1,48 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_notification.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_AppNotification _$AppNotificationFromJson(Map<String, dynamic> json) =>
_AppNotification(
data: SnNotification.fromJson(json['data'] as Map<String, dynamic>),
createdAt:
json['created_at'] == null
? null
: DateTime.parse(json['created_at'] as String),
);
Map<String, dynamic> _$AppNotificationToJson(_AppNotification instance) =>
<String, dynamic>{
'data': instance.data.toJson(),
'created_at': instance.createdAt?.toIso8601String(),
};
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$appNotificationsHash() => r'a7e7e1d1533e329b000d4b294e455b8420ec3c4d';
/// See also [AppNotifications].
@ProviderFor(AppNotifications)
final appNotificationsProvider = AutoDisposeNotifierProvider<
AppNotifications,
List<AppNotification>
>.internal(
AppNotifications.new,
name: r'appNotificationsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$appNotificationsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AppNotifications = AutoDisposeNotifier<List<AppNotification>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,49 +1,52 @@
import 'dart:io'; import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_notification.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
class WindowScaffold extends HookConsumerWidget { class WindowScaffold extends HookConsumerWidget {
final Widget child; final Widget child;
final AppRouter router; const WindowScaffold({super.key, required this.child});
const WindowScaffold({super.key, required this.child, required this.router});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Add window resize listener for desktop platforms // Add window resize listener for desktop platforms
useEffect(() { useEffect(() {
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
void saveWindowSize() { void saveWindowSize() {
final size = appWindow.size; final size = appWindow.size;
final settingsNotifier = ref.read(appSettingsNotifierProvider.notifier); final settingsNotifier = ref.read(
appSettingsNotifierProvider.notifier,
);
settingsNotifier.setWindowSize(size); settingsNotifier.setWindowSize(size);
} }
// Save window size when app is about to close // Save window size when app is about to close
WidgetsBinding.instance.addObserver(_WindowSizeObserver(saveWindowSize)); WidgetsBinding.instance.addObserver(
_WindowSizeObserver(saveWindowSize),
);
return () { return () {
// Cleanup observer when widget is disposed // Cleanup observer when widget is disposed
WidgetsBinding.instance.removeObserver(_WindowSizeObserver(saveWindowSize)); WidgetsBinding.instance.removeObserver(
_WindowSizeObserver(saveWindowSize),
);
}; };
} }
return null; return null;
}, []); }, []);
if (!kIsWeb && if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
@ -106,7 +109,6 @@ class WindowScaffold extends HookConsumerWidget {
], ],
), ),
_WebSocketIndicator(), _WebSocketIndicator(),
AppNotificationToast(),
], ],
), ),
); );
@ -114,39 +116,37 @@ class WindowScaffold extends HookConsumerWidget {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [Positioned.fill(child: child), _WebSocketIndicator()],
Positioned.fill(child: child),
_WebSocketIndicator(),
AppNotificationToast(),
],
); );
} }
} }
class _WindowSizeObserver extends WidgetsBindingObserver { class _WindowSizeObserver extends WidgetsBindingObserver {
final VoidCallback onSaveWindowSize; final VoidCallback onSaveWindowSize;
_WindowSizeObserver(this.onSaveWindowSize); _WindowSizeObserver(this.onSaveWindowSize);
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
// Save window size when app is paused, detached, or hidden // Save window size when app is paused, detached, or hidden
if (state == AppLifecycleState.paused || if (state == AppLifecycleState.paused ||
state == AppLifecycleState.detached || state == AppLifecycleState.detached ||
state == AppLifecycleState.hidden) { state == AppLifecycleState.hidden) {
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
onSaveWindowSize(); onSaveWindowSize();
} }
} }
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return other is _WindowSizeObserver && other.onSaveWindowSize == onSaveWindowSize; return other is _WindowSizeObserver &&
other.onSaveWindowSize == onSaveWindowSize;
} }
@override @override
int get hashCode => onSaveWindowSize.hashCode; int get hashCode => onSaveWindowSize.hashCode;
} }
@ -235,7 +235,7 @@ class PageBackButton extends StatelessWidget {
return IconButton( return IconButton(
onPressed: () { onPressed: () {
onWillPop?.call(); onWillPop?.call();
context.router.maybePop(); context.pop();
}, },
icon: Icon( icon: Icon(
color: color, color: color,

View File

@ -1,23 +1,30 @@
import 'package:auto_route/auto_route.dart'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/sharing_intent.dart'; import 'package:island/services/sharing_intent.dart';
import 'package:island/widgets/tour/tour.dart';
@RoutePage()
class AppWrapper extends HookConsumerWidget { class AppWrapper extends HookConsumerWidget {
const AppWrapper({super.key}); final Widget child;
const AppWrapper({super.key, required this.child});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
useEffect(() { useEffect(() {
StreamSubscription? ntySubs;
Future(() {
if (context.mounted) ntySubs = setupNotificationListener(context, ref);
});
final sharingService = SharingIntentService(); final sharingService = SharingIntentService();
sharingService.initialize(context); sharingService.initialize(context);
return () { return () {
sharingService.dispose(); sharingService.dispose();
ntySubs?.cancel();
}; };
}, const []); }, const []);
return AutoRouter(); return TourTriggerWidget(child: child);
} }
} }

View File

@ -1,12 +1,11 @@
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/pods/call.dart'; import 'package:island/pods/call.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -45,7 +44,7 @@ class AudioCallButton extends HookConsumerWidget {
try { try {
await apiClient.post('/chat/realtime/$roomId'); await apiClient.post('/chat/realtime/$roomId');
if (context.mounted) { if (context.mounted) {
context.router.push(CallRoute(roomId: roomId)); context.push('/chat/call/roomId');
} }
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
@ -97,7 +96,7 @@ class AudioCallButton extends HookConsumerWidget {
tooltip: 'Join Ongoing Call', tooltip: 'Join Ongoing Call',
onPressed: () { onPressed: () {
if (context.mounted) { if (context.mounted) {
context.router.push(CallRoute(roomId: roomId)); context.push('/chat/call/roomId');
} }
}, },
); );

View File

@ -1,10 +1,10 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart'; import 'package:island/pods/call.dart';
import 'package:island/route.gr.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -175,14 +175,7 @@ class CallControlsBar extends HookConsumerWidget {
}, },
); );
} catch (e) { } catch (e) {
if (context.mounted) { showErrorAlert(e);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${'failedToEnumerateDevices'.tr()}: $e'),
backgroundColor: Colors.red,
),
);
}
} }
} }
@ -367,7 +360,7 @@ class CallOverlayBar extends HookConsumerWidget {
).padding(all: 16), ).padding(all: 16),
), ),
onTap: () { onTap: () {
context.router.push(CallRoute(roomId: callNotifier.roomId!)); context.push('/chat/call/callNotifier.roomId!');
}, },
); );
} }

View File

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

View File

@ -1,15 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/activity.dart'; import 'package:island/models/activity.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/route.gr.dart';
import 'package:island/screens/auth/captcha.dart'; import 'package:island/screens/auth/captcha.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@ -137,7 +136,7 @@ class CheckInWidget extends HookConsumerWidget {
if (todayResult.valueOrNull == null) { if (todayResult.valueOrNull == null) {
checkIn(); checkIn();
} else { } else {
context.router.push(EventCalanderRoute(name: 'me')); context.push('/account/me/calendar');
} }
}, },
icon: AnimatedSwitcher( icon: AnimatedSwitcher(

View File

@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:path/path.dart' show extension; import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -185,13 +186,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
Future<void> saveToGallery() async { Future<void> saveToGallery() async {
try { try {
// Show loading indicator // Show loading indicator
final scaffold = ScaffoldMessenger.of(context); showSnackBar('Saving image to gallery...');
scaffold.showSnackBar(
const SnackBar(
content: Text('Saving image to gallery...'),
duration: Duration(seconds: 1),
),
);
// Get the image URL // Get the image URL
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
@ -208,21 +203,9 @@ class CloudFileZoomIn extends HookConsumerWidget {
await Gal.putImage(filePath, album: 'Solar Network'); await Gal.putImage(filePath, album: 'Solar Network');
// Show success message // Show success message
scaffold.showSnackBar( showSnackBar('Image saved to gallery');
const SnackBar(
content: Text('Image saved to gallery'),
duration: Duration(seconds: 2),
),
);
} catch (e) { } catch (e) {
// Show error message showErrorAlert(e);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to save image: $e'),
duration: const Duration(seconds: 2),
),
);
} }
} }

View File

@ -1,6 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_highlight/themes/a11y-dark.dart'; import 'package:flutter_highlight/themes/a11y-dark.dart';
import 'package:flutter_highlight/themes/a11y-light.dart'; import 'package:flutter_highlight/themes/a11y-light.dart';
@ -74,7 +74,7 @@ class MarkdownTextContent extends HookConsumerWidget {
final url = Uri.tryParse(href); final url = Uri.tryParse(href);
if (url != null) { if (url != null) {
if (url.scheme == 'solian') { if (url.scheme == 'solian') {
context.router.pushPath( context.push(
['', url.host, ...url.pathSegments].join('/'), ['', url.host, ...url.pathSegments].join('/'),
); );
return; return;
@ -94,7 +94,6 @@ class MarkdownTextContent extends HookConsumerWidget {
}); });
} else { } else {
showSnackBar( showSnackBar(
context,
'brokenLink'.tr(args: [href]), 'brokenLink'.tr(args: [href]),
action: SnackBarAction( action: SnackBarAction(
label: 'copyToClipboard'.tr(), label: 'copyToClipboard'.tr(),

View File

@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ConditionalBottomNav extends HookConsumerWidget {
final Widget child;
const ConditionalBottomNav({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLocation = GoRouterState.of(context).uri.toString();
// Force rebuild when route changes
useEffect(() {
// This effect will run whenever currentLocation changes
return null;
}, [currentLocation]);
// Use the same route logic as TabsScreen for consistency
const mainTabRoutes = ['/', '/chat', '/realms', '/account'];
final shouldShowBottomNav = mainTabRoutes.contains(currentLocation);
return shouldShowBottomNav ? child : const SizedBox.shrink();
}
}

View File

@ -106,7 +106,9 @@ class _PaymentContent extends ConsumerStatefulWidget {
class _PaymentContentState extends ConsumerState<_PaymentContent> { class _PaymentContentState extends ConsumerState<_PaymentContent> {
static const String _pinStorageKey = 'app_pin_code'; static const String _pinStorageKey = 'app_pin_code';
static final _secureStorage = FlutterSecureStorage(); static final _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
);
final LocalAuthentication _localAuth = LocalAuthentication(); final LocalAuthentication _localAuth = LocalAuthentication();
@ -279,7 +281,7 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> {
_isPinMode = true; _isPinMode = true;
}); });
if (message != null && message.isNotEmpty) { if (message != null && message.isNotEmpty) {
showSnackBar(context, message); showSnackBar(message);
} }
} }

Some files were not shown because too many files have changed in this diff Show More