Compare commits

...

24 Commits

Author SHA1 Message Date
6a4a50b81a 🚀 Launch 3.5.0+161 (SNAPSHOT) 2026-01-09 02:02:52 +08:00
5e328b736f App shortcuts 2026-01-09 01:58:29 +08:00
d44e481a0d 💄 Optimize the two parts split avatar displaying 2026-01-09 01:41:21 +08:00
e6fb38572c App intents (aims to support Siri on iOS) (untested) 2026-01-09 01:39:08 +08:00
64c5ee0fe9 🐛 Trying to fix NSE attachment doesn't work as expected on the default types 2026-01-08 23:10:22 +08:00
9cba568e47 💄 New media post layout 2026-01-07 23:30:48 +08:00
38fc4e969f 💄 Improved pinned post 2026-01-07 22:52:46 +08:00
0e100c3e64 💄 Better pinned post view 2026-01-07 22:46:36 +08:00
735724ae4d 🐛 Fix some bugs within relationship forgot 2026-01-07 22:31:16 +08:00
6c31337fbc 🐛 Optimize the disposal check in message notifier, close #211 2026-01-07 19:46:07 +08:00
d2806fdc3a 💄 Optimize timeline cursor picking 2026-01-07 19:44:53 +08:00
4ebbda1b14 🐛 Trying to fix timeline duplicate posts, close #227 2026-01-07 01:55:10 +08:00
3e7007f58f 🐛 Trying to fix app scaffold focus conflict 2026-01-07 01:52:25 +08:00
d96629a7f1 🐛 Fix universal image still got state changes after unmounted 2026-01-07 01:48:20 +08:00
1b6ccccf32 💫 Animated profile card 2026-01-07 01:43:46 +08:00
910dafaa43 🐛 Fix rename didn't commited 2026-01-07 01:43:03 +08:00
c6fd342072 ♻️ Refactored the relationship screen according to server updates 2026-01-07 01:42:20 +08:00
757f1b880c 🐛 Fix explore pagination placeholder maxWidth 2026-01-07 01:22:28 +08:00
67805b413e 🐛 Fix regex in markdown 2026-01-06 01:08:09 +08:00
e1eb0f705e Prevent the non-user name got rendered as mention chip, close #226 2026-01-05 22:46:40 +08:00
1e8e785e5f Adjust riverpod auto retries logic 2026-01-05 22:35:52 +08:00
d9dfea6b8b 🐛 Fix creation date parsing in iOS code 2026-01-05 22:14:29 +08:00
d29f0b1170 Shuffle post iOS widget 2026-01-05 22:13:26 +08:00
479a79c7f6 Circular accessory on iOS 2026-01-04 23:03:18 +08:00
52 changed files with 4201 additions and 2253 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "Blocked",
"blockUser": "Block User",
"unblockUser": "Unblock User",
"forgotRelationship": "Olvidar Relación",
"forgotRelationshipConfirm": "¿Estás seguro de que quieres olvidar tu relación con {}?",
"relationshipDeleted": "Relación eliminada",
"friendRequestAccepted": "Accepted friend request from {}",
"friendRequestDeclined": "Declined friend request from {}",
"requestExpiredIn": "Expired in {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "Blocked",
"blockUser": "Block User",
"unblockUser": "Unblock User",
"forgotRelationship": "関係を忘れる",
"forgotRelationshipConfirm": "{} との関係を忘れてもよろしいですか?",
"relationshipDeleted": "関係が削除されました",
"friendRequestAccepted": "Accepted friend request from {}",
"friendRequestDeclined": "Declined friend request from {}",
"requestExpiredIn": "Expired in {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "Blocked",
"blockUser": "Block User",
"unblockUser": "Unblock User",
"forgotRelationship": "관계 잊기",
"forgotRelationshipConfirm": "{}와의 관계를 잊으시겠습니까?",
"relationshipDeleted": "관계가 삭제되었습니다",
"friendRequestAccepted": "Accepted friend request from {}",
"friendRequestDeclined": "Declined friend request from {}",
"requestExpiredIn": "Expired in {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已屏蔽",
"blockUser": "屏蔽用户",
"unblockUser": "解除屏蔽用户",
"forgotRelationship": "忘记关系",
"forgotRelationshipConfirm": "确定要忘记与 {} 的关系吗?这只会改变您对他/她的关系,他可能看到不到您的友限帖,但是您仍然可以看到他/她的。他/她也可以再次提出好友申请。",
"relationshipDeleted": "关系已删除",
"friendRequestAccepted": "已接受 {} 的好友请求",
"friendRequestDeclined": "已拒绝 {} 的好友请求",
"requestExpiredIn": "{} 后过期",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已阻",
"blockUser": "禁此戶",
"unblockUser": "解禁",
"forgotRelationship": "忘締交",
"forgotRelationshipConfirm": "確定要忘與 {} 之締交乎?",
"relationshipDeleted": "締交已忘",
"friendRequestAccepted": "已納 {} 締交書",
"friendRequestDeclined": "已謝 {} 締交書",
"requestExpiredIn": "效期 {}",

View File

@@ -265,6 +265,9 @@
"relationshipStatusBlocked": "已屏蔽",
"blockUser": "屏蔽用戶",
"unblockUser": "解除屏蔽用戶",
"forgotRelationship": "忘記關係",
"forgotRelationshipConfirm": "確定要忘記與 {} 的關係嗎?",
"relationshipDeleted": "關係已刪除",
"friendRequestAccepted": "已接受 {} 的好友請求",
"friendRequestDeclined": "已拒絕 {} 的好友請求",
"requestExpiredIn": "{} 後過期",

View File

@@ -1,4 +1,4 @@
platform :ios, '15.0'
platform :ios, '16.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -125,6 +125,8 @@ PODS:
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- Flutter (1.0.0)
- flutter_app_intents (0.1.0):
- Flutter
- flutter_app_update (0.0.1):
- Flutter
- flutter_inappwebview_ios (0.0.1):
@@ -269,6 +271,8 @@ PODS:
- PromisesObjC (= 2.4.0)
- protocol_handler_ios (0.0.1):
- Flutter
- quick_actions_ios (0.0.1):
- Flutter
- receive_sharing_intent (1.8.1):
- Flutter
- record_ios (1.1.0):
@@ -336,6 +340,7 @@ DEPENDENCIES:
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_app_intents (from `.symlinks/plugins/flutter_app_intents/ios`)
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
@@ -362,6 +367,7 @@ DEPENDENCIES:
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- quick_actions_ios (from `.symlinks/plugins/quick_actions_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
@@ -428,6 +434,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
flutter_app_intents:
:path: ".symlinks/plugins/flutter_app_intents/ios"
flutter_app_update:
:path: ".symlinks/plugins/flutter_app_update/ios"
flutter_inappwebview_ios:
@@ -476,6 +484,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios:
:path: ".symlinks/plugins/protocol_handler_ios/ios"
quick_actions_ios:
:path: ".symlinks/plugins/quick_actions_ios/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
record_ios:
@@ -525,6 +535,7 @@ SPEC CHECKSUMS:
FirebaseRemoteConfigInterop: 3443b8cb8fffd76bb3e03b2a84bfd3db952fcda4
FirebaseSessions: 2e8f808347e665dff3e5843f275715f07045297d
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_intents: e77f999f398c841ab584a1925dbce33ee0168fb5
flutter_app_update: 816fdb2e30e4832a7c45e3f108d391c42ef040a9
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
@@ -561,6 +572,7 @@ SPEC CHECKSUMS:
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
quick_actions_ios: 500fcc11711d9f646739093395c4ae8eec25f779
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
@@ -578,6 +590,6 @@ SPEC CHECKSUMS:
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
PODFILE CHECKSUM: 585198f58dca90ac6492607c83a8d17045ab3852
PODFILE CHECKSUM: 5a3652182a0f0e1093d3ac36dcabc72f6cc46b3a
COCOAPODS: 1.16.2

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -216,6 +216,8 @@
};
7310A7D52EB10962002C0FD3 /* Solian Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = "Solian Watch App";
sourceTree = "<group>";
};
@@ -757,14 +759,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -822,14 +820,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -880,14 +874,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Solian Watch App/Pods-Solian Watch App-frameworks.sh\"\n";
@@ -1106,7 +1096,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1607,7 +1597,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1652,7 +1642,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1694,7 +1684,7 @@
INFOPLIST_FILE = SolianShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SolianShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1960,7 +1950,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1991,7 +1981,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -2,6 +2,8 @@ import Flutter
import WidgetKit
import UIKit
import WatchConnectivity
import AppIntents
import flutter_app_intents
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -107,7 +109,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
let token = UserDefaults.standard.getFlutterToken()
let serverUrl = UserDefaults.standard.getServerUrl()
var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
var data: [String: Any] = ["serverUrl": serverUrl]
if let token = token {
data["token"] = token
}
@@ -125,7 +127,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
let token = UserDefaults.standard.getFlutterToken()
let serverUrl = UserDefaults.standard.getServerUrl()
var data: [String: Any] = ["serverUrl": serverUrl ?? ""]
var data: [String: Any] = ["serverUrl": serverUrl]
if let token = token {
data["token"] = token
}
@@ -138,3 +140,240 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
}
}
}
// MARK: - App Intents
@available(iOS 16.0, *)
struct OpenChatIntent: AppIntent {
static var title: LocalizedStringResource = "Open Chat"
static var description = IntentDescription("Open a specific chat room")
static var isDiscoverable = true
static var openAppWhenRun = true
@Parameter(title: "Channel ID")
var channelId: String?
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "open_chat",
parameters: ["channelId": channelId ?? ""]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "Chat opened"
return .result(value: value)
} else {
let errorMessage = result["error"] as? String ?? "Failed to open chat"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
@available(iOS 16.0, *)
struct OpenPostIntent: AppIntent {
static var title: LocalizedStringResource = "Open Post"
static var description = IntentDescription("Open a specific post")
static var isDiscoverable = true
static var openAppWhenRun = true
@Parameter(title: "Post ID")
var postId: String?
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "open_post",
parameters: ["postId": postId ?? ""]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "Post opened"
return .result(value: value)
} else {
let errorMessage = result["error"] as? String ?? "Failed to open post"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
@available(iOS 16.0, *)
struct OpenComposeIntent: AppIntent {
static var title: LocalizedStringResource = "Open Compose"
static var description = IntentDescription("Open compose post screen")
static var isDiscoverable = true
static var openAppWhenRun = true
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "open_compose",
parameters: [:]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "Compose screen opened"
return .result(value: value)
} else {
let errorMessage = result["error"] as? String ?? "Failed to open compose"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
@available(iOS 16.0, *)
struct ComposePostIntent: AppIntent {
static var title: LocalizedStringResource = "Compose Post"
static var description = IntentDescription("Create a new post")
static var isDiscoverable = true
static var openAppWhenRun = true
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "compose_post",
parameters: [:]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "Compose screen opened"
return .result(value: value)
} else {
let errorMessage = result["error"] as? String ?? "Failed to compose post"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
@available(iOS 16.0, *)
struct SearchContentIntent: AppIntent {
static var title: LocalizedStringResource = "Search Content"
static var description = IntentDescription("Search for content")
static var isDiscoverable = true
static var openAppWhenRun = true
@Parameter(title: "Search Query")
var query: String?
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "search_content",
parameters: ["query": query ?? ""]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "Search opened"
return .result(value: value)
} else {
let errorMessage = result["error"] as? String ?? "Failed to search"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
@available(iOS 16.0, *)
struct ViewNotificationsIntent: AppIntent {
static var title: LocalizedStringResource = "View Notifications"
static var description = IntentDescription("View notifications")
static var isDiscoverable = true
static var openAppWhenRun = true
func perform() async throws -> some IntentResult & ReturnsValue<String> & OpensIntent {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "view_notifications",
parameters: [:]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "Notifications opened"
return .result(value: value)
} else {
let errorMessage = result["error"] as? String ?? "Failed to view notifications"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
@available(iOS 16.0, *)
struct CheckNotificationsIntent: AppIntent {
static var title: LocalizedStringResource = "Check Notifications"
static var description = IntentDescription("Check notification count")
static var isDiscoverable = true
static var openAppWhenRun = false
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
let plugin = FlutterAppIntentsPlugin.shared
let result = await plugin.handleIntentInvocation(
identifier: "check_notifications",
parameters: [:]
)
if let success = result["success"] as? Bool, success {
let value = result["value"] as? String ?? "You have new notifications"
return .result(
value: value,
dialog: "Dialog: \(value)"
)
} else {
let errorMessage = result["error"] as? String ?? "Failed to check notifications"
throw AppIntentError.executionFailed(errorMessage)
}
}
}
enum AppIntentError: Error {
case executionFailed(String)
}
@available(iOS 16.0, *)
struct AppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
return [
// Open chat
AppShortcut(
intent: OpenChatIntent(),
phrases: [
"Open chat with \(.applicationName)",
"Go to chat using \(.applicationName)",
"Show chat in \(.applicationName)"
]
),
// Open post
AppShortcut(
intent: OpenPostIntent(),
phrases: [
"Open post with \(.applicationName)",
"Show post using \(.applicationName)"
]
),
// Compose
AppShortcut(
intent: OpenComposeIntent(),
phrases: [
"Open compose with \(.applicationName)",
"New post using \(.applicationName)",
"Write post in \(.applicationName)"
]
),
// Search
AppShortcut(
intent: SearchContentIntent(),
phrases: [
"Search in \(.applicationName)",
"Find content using \(.applicationName)"
]
),
// Check notifications
AppShortcut(
intent: CheckNotificationsIntent(),
phrases: [
"Check notifications with \(.applicationName)",
"Get notifications using \(.applicationName)",
"Do I have notifications in \(.applicationName)"
]
)
]
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -63,6 +63,18 @@
<string>Allow the Solar Network verify your ownership of the logged in account and continue your action quickly.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Grant access to Microphone will allow Solian record audio for your post.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Solian uses speech recognition for Siri integration</string>
<key>NSAppIntentsConfiguration</key>
<dict>
<key>NSAppIntentsPackage</key>
<string>dev.solsynth.solian</string>
</dict>
<key>NSAppIntentsMetadata</key>
<dict>
<key>NSAppIntentsSupported</key>
<true/>
</dict>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
<key>NSPhotoLibraryUsageDescription</key>

View File

@@ -5,7 +5,7 @@
// Created by LittleSheep on 2025/5/31.
//
import UserNotifications
@preconcurrency import UserNotifications
import Intents
import Kingfisher
import UniformTypeIdentifiers
@@ -149,7 +149,7 @@ class NotificationService: UNNotificationServiceExtension {
let dispatchGroup = DispatchGroup()
var attachments: [UNNotificationAttachment] = []
let lock = NSLock() // To synchronize access to the attachments array
let queue = DispatchQueue(label: "dev.solsynth.solian.nse")
for attachmentUrl in attachmentUrls {
guard let remoteUrl = URL(string: attachmentUrl) else {
@@ -163,7 +163,7 @@ class NotificationService: UNNotificationServiceExtension {
.processor(scaleProcessor)
] : nil) { [weak self] result in
defer { dispatchGroup.leave() }
guard let self = self else { return }
guard self != nil else { return }
switch result {
case .success(let retrievalResult):
@@ -176,12 +176,12 @@ class NotificationService: UNNotificationServiceExtension {
try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
if let attachment = try? UNNotificationAttachment(identifier: attachmentUrl, url: cachedFileUrl, options: [
UNNotificationAttachmentOptionsTypeHintKey: type?.identifier as Any,
UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
]) {
lock.lock()
attachments.append(attachment)
lock.unlock()
queue.async {
attachments.append(attachment)
}
}
} catch {
print("Failed to write media to temporary file: \(error.localizedDescription)")

View File

@@ -56,7 +56,16 @@ struct CheckInResult: Codable {
}
var createdDate: Date? {
ISO8601DateFormatter().date(from: createdAt)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: createdAt) {
return date
}
// Fallback for timestamps without fractional seconds
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: createdAt)
}
}

View File

@@ -53,7 +53,16 @@ struct SnNotification: Codable, Identifiable {
}
var createdDate: Date? {
ISO8601DateFormatter().date(from: createdAt)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: createdAt) {
return date
}
// Fallback for timestamps without fractional seconds
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: createdAt)
}
var isUnread: Bool {
@@ -276,6 +285,18 @@ struct NotificationWidgetEntryView: View {
if case .accessoryRectangular = family {
return true
}
if case .accessoryCircular = family {
return true
}
}
return false
}
private var isCircular: Bool {
if #available(iOS 16.0, *) {
if case .accessoryCircular = family {
return true
}
}
return false
}
@@ -285,40 +306,57 @@ struct NotificationWidgetEntryView: View {
Link(destination: URL(string: "solian://notifications")!) {
if isCompact {
if isAccessory {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
if isCircular {
ZStack {
Image(systemName: "bell.fill")
.font(.caption2)
.foregroundColor(.orange)
.padding(.leading, 1.5)
.font(.system(size: 20))
.foregroundColor(unreadCount > 0 ? .orange : .gray)
Text(NSLocalizedString("notifications", comment: "Notifications"))
.font(.caption2)
.fontWeight(.bold)
Spacer()
}
if unreadCount > 0 {
HStack(spacing: 4) {
Text("\(unreadCount)")
.font(.caption2)
.fontWeight(.bold)
.padding(.horizontal, 6)
.background(
Capsule()
.fill(Color.blue.opacity(0.5))
)
Text(NSLocalizedString("unread", comment: "unread"))
.font(.caption2)
if unreadCount > 0 {
Text("\(min(unreadCount, 99))")
.font(.system(size: 10, weight: .bold))
.foregroundColor(.white)
.padding(4)
.background(Circle().fill(Color.blue))
.offset(x: 12, y: -12)
}
}
Text("on the Solar Network")
.font(.caption2)
.foregroundColor(.secondary)
.padding(.horizontal, 1.5)
} else {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Image(systemName: "bell.fill")
.font(.caption2)
.foregroundColor(.orange)
.padding(.leading, 1.5)
Text(NSLocalizedString("notifications", comment: "Notifications"))
.font(.caption2)
.fontWeight(.bold)
Spacer()
}
if unreadCount > 0 {
HStack(spacing: 4) {
Text("\(unreadCount)")
.font(.caption2)
.fontWeight(.bold)
.padding(.horizontal, 6)
.background(
Capsule()
.fill(Color.blue.opacity(0.5))
)
Text(NSLocalizedString("unread", comment: "unread"))
.font(.caption2)
}
}
Text("on the Solar Network")
.font(.caption2)
.foregroundColor(.secondary)
.padding(.horizontal, 1.5)
}
}
} else {
VStack(alignment: .leading, spacing: 8) {
@@ -490,79 +528,100 @@ struct NotificationWidgetEntryView: View {
@ViewBuilder
private func EmptyView() -> some View {
Link(destination: URL(string: "solian://notifications")!) {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
if isCircular {
ZStack {
Image(systemName: "bell")
.font(isAccessory ? .caption : .title3)
.foregroundColor(.secondary)
Text(NSLocalizedString("notifications", comment: "Notifications"))
.font(isAccessory ? .caption2 : .headline)
.fontWeight(.bold)
Spacer()
.font(.system(size: 20))
.foregroundColor(.gray)
}
if !isAccessory {
Text(NSLocalizedString("noNotifications", comment: "No notifications yet"))
.font(.caption)
.foregroundColor(.secondary)
} else {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
Image(systemName: "bell")
.font(isAccessory ? .caption : .title3)
.foregroundColor(.secondary)
Text(NSLocalizedString("notifications", comment: "Notifications"))
.font(isAccessory ? .caption2 : .headline)
.fontWeight(.bold)
Spacer()
}
Spacer()
if !isAccessory {
Text(NSLocalizedString("noNotifications", comment: "No notifications yet"))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
}
.padding(isAccessory ? 4 : 12)
}
.padding(isAccessory ? 4 : 12)
}
}
@ViewBuilder
private func LoadingView() -> some View {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
ProgressView()
.scaleEffect(isAccessory ? 0.6 : 0.8)
Text(NSLocalizedString("loading", comment: "Loading..."))
.font(isAccessory ? .caption2 : .caption)
.foregroundColor(.secondary)
Spacer()
}
if !isAccessory {
Spacer()
if isCircular {
ProgressView()
.scaleEffect(0.8)
} else {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
ProgressView()
.scaleEffect(isAccessory ? 0.6 : 0.8)
Text(NSLocalizedString("loading", comment: "Loading..."))
.font(isAccessory ? .caption2 : .caption)
.foregroundColor(.secondary)
Spacer()
}
if !isAccessory {
Spacer()
}
}
.padding(isAccessory ? 4 : 12)
}
.padding(isAccessory ? 4 : 12)
}
@ViewBuilder
private func ErrorView(error: String) -> some View {
Link(destination: URL(string: "solian://notifications")!) {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
if isCircular {
ZStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.secondary)
.font(isAccessory ? .caption : .title3)
Text(NSLocalizedString("error", comment: "Error"))
.font(isAccessory ? .caption2 : .headline)
Spacer()
.font(.system(size: 20))
.foregroundColor(.red)
}
if !isAccessory {
Text(NSLocalizedString("openAppToRefresh", comment: "Open app to refresh"))
.font(.caption)
.foregroundColor(.secondary)
} else {
VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.secondary)
.font(isAccessory ? .caption : .title3)
Text(NSLocalizedString("error", comment: "Error"))
.font(isAccessory ? .caption2 : .headline)
Spacer()
}
Text(error)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(nil)
.multilineTextAlignment(.leading)
Spacer()
if !isAccessory {
Text(NSLocalizedString("openAppToRefresh", comment: "Open app to refresh"))
.font(.caption)
.foregroundColor(.secondary)
Text(error)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(nil)
.multilineTextAlignment(.leading)
Spacer()
}
}
.padding(isAccessory ? 4 : 12)
}
.padding(isAccessory ? 4 : 12)
}
}
@@ -647,13 +706,14 @@ struct SolianNotificationWidget: Widget {
private var supportedFamilies: [WidgetFamily] {
#if os(iOS)
return [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular]
return [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryCircular]
#else
return [.systemSmall, .systemMedium, .systemLarge]
#endif
}
}
#if os(iOS)
#Preview(as: .accessoryRectangular) {
SolianNotificationWidget()
} timeline: {
@@ -694,6 +754,7 @@ struct SolianNotificationWidget: Widget {
isLoading: false
)
}
#endif
#Preview(as: .systemSmall) {
SolianNotificationWidget()
@@ -846,4 +907,31 @@ struct SolianNotificationWidget: Widget {
isLoading: false
)
}
#Preview(as: .accessoryCircular) {
SolianNotificationWidget()
} timeline: {
NotificationEntry(
date: .now,
notifications: [
SnNotification(
id: "1",
topic: "post.replies",
title: "New reply",
subtitle: "Someone replied",
content: "Content",
meta: nil,
priority: 0,
viewedAt: nil,
accountId: "acc-1",
createdAt: ISO8601DateFormatter().string(from: Date()),
updatedAt: ISO8601DateFormatter().string(from: Date()),
deletedAt: nil
)
],
unreadCount: 5,
error: nil,
isLoading: false
)
}
#endif

View File

@@ -0,0 +1,549 @@
//
// SolianPostShuffleWidget.swift
// SolianWidgetExtension
//
// Created by LittleSheep on 2026/1/4.
//
import Foundation
import WidgetKit
import SwiftUI
struct SnPostPublisher: Codable {
let id: String
let name: String
let nick: String?
let description: String?
let picture: SnCloudFile?
enum CodingKeys: String, CodingKey {
case id
case name
case nick
case description
case picture
}
}
struct SnCloudFile: Codable {
let id: String
let url: String?
let thumbnail: String?
let mimeType: String?
enum CodingKeys: String, CodingKey {
case id
case url
case thumbnail
case mimeType = "mimetype"
}
}
struct SnPost: Codable, Identifiable {
let id: String
let title: String?
let description: String?
let content: String?
let publisher: SnPostPublisher
let tags: [SnPostTag]?
let createdAt: String?
let updatedAt: String?
let attachments: [SnCloudFile]?
var createdDate: Date? {
guard let createdAt = createdAt else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: createdAt) {
return date
}
// Fallback for timestamps without fractional seconds
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: createdAt)
}
var hasTitle: Bool {
guard let title = title else { return false }
return !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var hasDescription: Bool {
guard let description = description else { return false }
return !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var hasContent: Bool {
guard let content = content else { return false }
return !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var attachmentCount: Int {
attachments?.count ?? 0
}
enum CodingKeys: String, CodingKey {
case id
case title
case description
case content
case publisher
case tags
case createdAt = "created_at"
case updatedAt = "updated_at"
case attachments
}
}
struct SnPostTag: Codable {
let id: String
let slug: String
let name: String?
}
class PostShuffleService {
private let networkService = WidgetNetworkService()
private lazy var session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 10.0
configuration.timeoutIntervalForResource = 10.0
configuration.waitsForConnectivity = false
return URLSession(configuration: configuration)
}()
func fetchRandomPost() async throws -> SnPost? {
guard let token = networkService.token else {
throw RemoteError.missingCredentials
}
let baseURL = networkService.baseURL
guard let url = URL(string: "\(baseURL)/sphere/posts?shuffle=true&take=1") else {
throw RemoteError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.timeoutInterval = 10.0
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw RemoteError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
let decoder = JSONDecoder()
let posts = try decoder.decode([SnPost].self, from: data)
return posts.first
case 404:
return nil
default:
throw RemoteError.httpError(httpResponse.statusCode)
}
}
}
struct PostShuffleEntry: TimelineEntry {
let date: Date
let post: SnPost?
let error: String?
let isLoading: Bool
static func placeholder() -> PostShuffleEntry {
PostShuffleEntry(date: Date(), post: nil, error: nil, isLoading: true)
}
}
struct PostShuffleProvider: TimelineProvider {
private let postShuffleService = PostShuffleService()
func placeholder(in context: Context) -> PostShuffleEntry {
PostShuffleEntry.placeholder()
}
func getSnapshot(in context: Context, completion: @escaping (PostShuffleEntry) -> ()) {
Task {
print("[WidgetKit] [PostShuffleProvider] Getting snapshot...")
let post = try? await postShuffleService.fetchRandomPost()
print("[WidgetKit] [PostShuffleProvider] Snapshot - Post: \(post != nil ? "Found" : "Not found")")
let entry = PostShuffleEntry(date: Date(), post: post, error: nil, isLoading: false)
completion(entry)
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task {
let currentDate = Date()
print("[WidgetKit] [PostShuffleProvider] Getting timeline at \(currentDate)...")
do {
let post = try await postShuffleService.fetchRandomPost()
print("[WidgetKit] [PostShuffleProvider] Timeline - Post: \(post != nil ? "Found" : "Not found")")
let entry = PostShuffleEntry(date: currentDate, post: post, error: nil, isLoading: false)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 10, to: currentDate)!
print("[WidgetKit] [PostShuffleProvider] Next update at: \(nextUpdate)")
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
} catch {
print("[WidgetKit] [PostShuffleProvider] Error in getTimeline: \(error.localizedDescription)")
let entry = PostShuffleEntry(date: currentDate, post: nil, error: error.localizedDescription, isLoading: false)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
}
}
}
struct PostShuffleWidgetEntryView: View {
var entry: PostShuffleProvider.Entry
@Environment(\.widgetFamily) var family
var body: some View {
if let post = entry.post {
PostContentView(post: post)
} else if entry.isLoading {
LoadingView()
} else if let error = entry.error {
ErrorView(error: error)
} else {
EmptyView()
}
}
@ViewBuilder
private func PostContentView(post: SnPost) -> some View {
Link(destination: URL(string: "solian://posts/\(post.id)")!) {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
if let avatarUrl = post.publisher.picture?.url ?? post.publisher.picture?.thumbnail {
AsyncImage(url: URL(string: avatarUrl)) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure(_):
Circle()
.fill(Color.gray.opacity(0.3))
default:
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.scaleEffect(0.5)
)
}
}
.frame(width: 40, height: 40)
.clipShape(Circle())
} else {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 40, height: 40)
}
VStack(alignment: .leading, spacing: 2) {
Text(post.publisher.nick ?? post.publisher.name)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(1)
if let createdDate = post.createdDate {
Text(formatRelativeTime(createdDate))
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
if post.attachmentCount > 0 {
HStack(spacing: 4) {
Image(systemName: "paperclip")
.font(.caption2)
.foregroundColor(.secondary)
Text("\(post.attachmentCount)")
.font(.caption2)
.foregroundColor(.secondary)
}
}
if post.hasTitle {
Text(post.title!)
.font(family == .systemMedium ? .subheadline : .body)
.fontWeight(.semibold)
.lineLimit(family == .systemMedium ? 2 : 3)
}
if post.hasDescription {
Text(post.description!)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(family == .systemMedium ? 2 : 4)
}
if post.hasContent {
Text(post.content!)
.font(.caption)
.foregroundColor(.primary)
.lineLimit(family == .systemMedium ? 3 : 8)
}
if let tags = post.tags, !tags.isEmpty {
let displayTags = Array(tags.prefix(family == .systemMedium ? 2 : 4))
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(displayTags, id: \.id) { tag in
Text("#\(tag.name ?? tag.slug)")
.font(.caption2)
.foregroundColor(.blue)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
}
}
}
}
Spacer()
}
.padding(12)
}
}
@ViewBuilder
private func EmptyView() -> some View {
Link(destination: URL(string: "solian://posts/shuffle")!) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "text.alignleft")
.font(.title3)
.foregroundColor(.secondary)
Spacer()
}
Text(NSLocalizedString("noPostsAvailable", comment: "No posts available"))
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(NSLocalizedString("tapToRefresh", comment: "Tap to refresh"))
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
}
}
@ViewBuilder
private func LoadingView() -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
ProgressView()
.scaleEffect(0.8)
Text(NSLocalizedString("loadingPost", comment: "Loading post..."))
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
Spacer()
}
.padding(12)
}
@ViewBuilder
private func ErrorView(error: String) -> some View {
Link(destination: URL(string: "solian://posts/shuffle")!) {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle")
.font(.title3)
.foregroundColor(.secondary)
Text(NSLocalizedString("error", comment: "Error"))
.font(.headline)
Spacer()
}
Text(NSLocalizedString("openAppToRefresh", comment: "Open app to refresh"))
.font(.caption)
.foregroundColor(.secondary)
Text(error)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(5)
.multilineTextAlignment(.leading)
Spacer()
}
.padding(12)
}
}
private func formatRelativeTime(_ date: Date) -> String {
let now = Date()
let interval = now.timeIntervalSince(date)
if interval < 60 {
return NSLocalizedString("justNow", comment: "Just now")
} else if interval < 3600 {
let minutes = Int(interval / 60)
return String(format: NSLocalizedString("minutesAgo", comment: "%d min ago"), minutes)
} else if interval < 86400 {
let hours = Int(interval / 3600)
return String(format: NSLocalizedString("hoursAgo", comment: "%d hr ago"), hours)
} else {
let days = Int(interval / 86400)
return String(format: NSLocalizedString("daysAgo", comment: "%d d ago"), days)
}
}
}
struct PostShuffleWidgetRootView: View {
var entry: PostShuffleProvider.Entry
@Environment(\.colorScheme) var colorScheme
var body: some View {
if #available(iOS 17.0, *) {
ZStack {
PostShuffleWidgetEntryView(entry: entry)
if entry.post != nil {
GeometryReader { geometry in
Image(colorScheme == .dark ? "CloudyLambDark" : "CloudyLamb")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(
width: geometry.size.width * 0.9,
height: geometry.size.width * 0.9
)
.opacity(0.12)
.mask(
LinearGradient(
gradient: Gradient(colors: [
Color.white,
Color.white,
Color.clear
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.position(
x: geometry.size.width * 0.9,
y: 20
)
}
.allowsHitTesting(false)
}
}
.containerBackground(.fill.tertiary, for: .widget)
.padding(.vertical, 8)
} else {
PostShuffleWidgetEntryView(entry: entry)
.padding()
.background()
}
}
}
struct SolianPostShuffleWidget: Widget {
let kind: String = "SolianPostShuffleWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: PostShuffleProvider()) { entry in
PostShuffleWidgetRootView(entry: entry)
}
.configurationDisplayName("Random Post")
.description("Discover a random post from the network")
.supportedFamilies(supportedFamilies)
}
private var supportedFamilies: [WidgetFamily] {
return [.systemMedium, .systemLarge]
}
}
#Preview(as: .systemMedium) {
SolianPostShuffleWidget()
} timeline: {
PostShuffleEntry(
date: .now,
post: SnPost(
id: "test-post-id",
title: "Hello World!",
description: "This is a test post description",
content: "This is a content of a test post. It can be longer and show more text in the widget.",
publisher: SnPostPublisher(
id: "publisher-1",
name: "Test Publisher",
nick: "Testy",
description: "A test publisher",
picture: nil
),
tags: [
SnPostTag(id: "tag-1", slug: "test", name: "Test"),
SnPostTag(id: "tag-2", slug: "example", name: "Example")
],
createdAt: ISO8601DateFormatter().string(from: Date()),
updatedAt: ISO8601DateFormatter().string(from: Date()),
attachments: []
),
error: nil,
isLoading: false
)
}
#Preview(as: .systemLarge) {
SolianPostShuffleWidget()
} timeline: {
PostShuffleEntry(
date: .now,
post: SnPost(
id: "test-post-id",
title: "Welcome to the Solar Network!",
description: "This is a test post description that is a bit longer to demonstrate the widget layout with various content types",
content: "This is content of a test post. It can be longer and show more text in the widget. The large widget should display more content and allow for better reading experience. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
publisher: SnPostPublisher(
id: "publisher-1",
name: "Test Publisher",
nick: "Testy McTestface",
description: "A test publisher",
picture: nil
),
tags: [
SnPostTag(id: "tag-1", slug: "test", name: "Test"),
SnPostTag(id: "tag-2", slug: "example", name: "Example"),
SnPostTag(id: "tag-3", slug: "widget", name: "Widget"),
SnPostTag(id: "tag-4", slug: "ios", name: "iOS")
],
createdAt: ISO8601DateFormatter().string(from: Date()),
updatedAt: ISO8601DateFormatter().string(from: Date()),
attachments: [
SnCloudFile(id: "file-1", url: nil, thumbnail: nil, mimeType: "image/jpeg"),
SnCloudFile(id: "file-2", url: nil, thumbnail: nil, mimeType: "image/png")
]
),
error: nil,
isLoading: false
)
}

View File

@@ -13,5 +13,6 @@ struct SolianWidgetExtensionBundle: WidgetBundle {
var body: some Widget {
SolianCheckInWidget()
SolianNotificationWidget()
SolianPostShuffleWidget()
}
}

View File

@@ -39,3 +39,8 @@
"noNotifications" = "No notifications";
"noUnreadNotifications" = "All notifications are read";
"unread" = "unread";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "No posts available";
"tapToRefresh" = "Tap to refresh";
"loadingPost" = "Loading post...";

View File

@@ -34,3 +34,8 @@
"invalidResponse" = "El servidor devolvió una respuesta no válida.";
"httpError" = "Error del servidor (%d).";
"decodingError" = "Error al leer los datos del servidor.";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "No hay publicaciones disponibles";
"tapToRefresh" = "Toca para actualizar";
"loadingPost" = "Cargando publicación...";

View File

@@ -34,3 +34,8 @@
"invalidResponse" = "サーバーから無効な応答が返されました。";
"httpError" = "サーバーエラー (%d)。";
"decodingError" = "サーバーデータの読み込みに失敗しました。";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "利用可能な投稿がありません";
"tapToRefresh" = "タップして更新";
"loadingPost" = "投稿を読み込み中...";

View File

@@ -34,3 +34,8 @@
"invalidResponse" = "서버에서 잘못된 응답을 받았습니다.";
"httpError" = "서버 오류 (%d).";
"decodingError" = "서버 데이터 읽기에 실패했습니다.";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "사용 가능한 게시물이 없습니다";
"tapToRefresh" = "탭하여 새로고침";
"loadingPost" = "게시물 로딩 중...";

View File

@@ -39,3 +39,8 @@
"noNotifications" = "没有通知";
"noUnreadNotifications" = "没有未读通知";
"unread" = "未读";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "没有可用的帖子";
"tapToRefresh" = "点击刷新";
"loadingPost" = "加载帖子中...";

View File

@@ -34,3 +34,8 @@
"invalidResponse" = "伺服器返回了無效響應。";
"httpError" = "伺服器錯誤 (%d)。";
"decodingError" = "讀取伺服器數據失敗。";
/* Post Shuffle Widget Strings */
"noPostsAvailable" = "沒有可用的帖子";
"tapToRefresh" = "點擊刷新";
"loadingPost" = "載入帖子中...";

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:croppy/croppy.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
@@ -22,6 +23,8 @@ import 'package:island/route.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/widget_sync_service.dart';
import 'package:island/services/timezone.dart';
import 'package:island/services/app_intents.dart';
import 'package:island/services/quick_actions.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:relative_time/relative_time.dart';
@@ -98,6 +101,28 @@ void main() async {
talker.error("[SplashScreen] Failed to load timezone database... $err");
}
try {
talker.info("[AppIntents] Initializing App Intents service...");
final appIntentsService = AppIntentsService();
await appIntentsService.initialize();
talker.info("[AppIntents] App Intents service is ready!");
} catch (err) {
talker.error(
"[AppIntents] Failed to initialize App Intents service... $err",
);
}
try {
talker.info("[QuickActions] Initializing Quick Actions service...");
final quickActionsService = QuickActionsService();
await quickActionsService.initialize();
talker.info("[QuickActions] Quick Actions service is ready!");
} catch (err) {
talker.error(
"[QuickActions] Failed to initialize Quick Actions service... $err",
);
}
final prefs = await SharedPreferences.getInstance();
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
@@ -170,6 +195,12 @@ void main() async {
runApp(
ProviderScope(
retry: (retryCount, error) {
if (error is DioException && error.response?.statusCode == 404) {
return null;
}
return const Duration(milliseconds: 300);
},
observers: [
TalkerRiverpodObserver(
talker: talker,

View File

@@ -12,9 +12,10 @@ sealed class SnRelationship with _$SnRelationship {
required DateTime? updatedAt,
required DateTime? deletedAt,
required String accountId,
required SnAccount account,
// Usually the account was not included in the response
required SnAccount? account,
required String relatedId,
required SnAccount related,
required SnAccount? related,
required DateTime? expiredAt,
required int status,
}) = _SnRelationship;

View File

@@ -15,7 +15,8 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnRelationship {
DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; String get accountId; SnAccount get account; String get relatedId; SnAccount get related; DateTime? get expiredAt; int get status;
DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; String get accountId;// Usually the account was not included in the response
SnAccount? get account; String get relatedId; SnAccount? get related; DateTime? get expiredAt; int get status;
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -48,11 +49,11 @@ abstract mixin class $SnRelationshipCopyWith<$Res> {
factory $SnRelationshipCopyWith(SnRelationship value, $Res Function(SnRelationship) _then) = _$SnRelationshipCopyWithImpl;
@useResult
$Res call({
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status
});
$SnAccountCopyWith<$Res> get account;$SnAccountCopyWith<$Res> get related;
$SnAccountCopyWith<$Res>? get account;$SnAccountCopyWith<$Res>? get related;
}
/// @nodoc
@@ -65,16 +66,16 @@ class _$SnRelationshipCopyWithImpl<$Res>
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = null,Object? relatedId = null,Object? related = null,Object? expiredAt = freezed,Object? status = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = freezed,Object? relatedId = null,Object? related = freezed,Object? expiredAt = freezed,Object? status = null,}) {
return _then(_self.copyWith(
createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
as String,related: null == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
as SnAccount,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
as String,related: freezed == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
as SnAccount?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int,
));
@@ -83,18 +84,24 @@ as int,
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get related {
return $SnAccountCopyWith<$Res>(_self.related, (value) {
$SnAccountCopyWith<$Res>? get related {
if (_self.related == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.related!, (value) {
return _then(_self.copyWith(related: value));
});
}
@@ -176,7 +183,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnRelationship() when $default != null:
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);case _:
@@ -197,7 +204,7 @@ return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status) $default,) {final _that = this;
switch (_that) {
case _SnRelationship():
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);}
@@ -214,7 +221,7 @@ return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status)? $default,) {final _that = this;
switch (_that) {
case _SnRelationship() when $default != null:
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);case _:
@@ -236,9 +243,10 @@ class _SnRelationship implements SnRelationship {
@override final DateTime? updatedAt;
@override final DateTime? deletedAt;
@override final String accountId;
@override final SnAccount account;
// Usually the account was not included in the response
@override final SnAccount? account;
@override final String relatedId;
@override final SnAccount related;
@override final SnAccount? related;
@override final DateTime? expiredAt;
@override final int status;
@@ -275,11 +283,11 @@ abstract mixin class _$SnRelationshipCopyWith<$Res> implements $SnRelationshipCo
factory _$SnRelationshipCopyWith(_SnRelationship value, $Res Function(_SnRelationship) _then) = __$SnRelationshipCopyWithImpl;
@override @useResult
$Res call({
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount account, String relatedId, SnAccount related, DateTime? expiredAt, int status
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status
});
@override $SnAccountCopyWith<$Res> get account;@override $SnAccountCopyWith<$Res> get related;
@override $SnAccountCopyWith<$Res>? get account;@override $SnAccountCopyWith<$Res>? get related;
}
/// @nodoc
@@ -292,16 +300,16 @@ class __$SnRelationshipCopyWithImpl<$Res>
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = null,Object? relatedId = null,Object? related = null,Object? expiredAt = freezed,Object? status = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = freezed,Object? relatedId = null,Object? related = freezed,Object? expiredAt = freezed,Object? status = null,}) {
return _then(_SnRelationship(
createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
as String,related: null == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
as SnAccount,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
as String,related: freezed == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
as SnAccount?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int,
));
@@ -311,18 +319,24 @@ as int,
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get related {
return $SnAccountCopyWith<$Res>(_self.related, (value) {
$SnAccountCopyWith<$Res>? get related {
if (_self.related == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.related!, (value) {
return _then(_self.copyWith(related: value));
});
}

View File

@@ -18,9 +18,13 @@ _SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) =>
? null
: DateTime.parse(json['deleted_at'] as String),
accountId: json['account_id'] as String,
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
account: json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
relatedId: json['related_id'] as String,
related: SnAccount.fromJson(json['related'] as Map<String, dynamic>),
related: json['related'] == null
? null
: SnAccount.fromJson(json['related'] as Map<String, dynamic>),
expiredAt: json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
@@ -33,9 +37,9 @@ Map<String, dynamic> _$SnRelationshipToJson(_SnRelationship instance) =>
'updated_at': instance.updatedAt?.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'account_id': instance.accountId,
'account': instance.account.toJson(),
'account': instance.account?.toJson(),
'related_id': instance.relatedId,
'related': instance.related.toJson(),
'related': instance.related?.toJson(),
'expired_at': instance.expiredAt?.toIso8601String(),
'status': instance.status,
};

View File

@@ -50,9 +50,6 @@ class MessagesNotifier extends _$MessagesNotifier {
late Future<SnAccount?> Function(String) _fetchAccount;
// Disposal handling
bool _disposed = false;
@override
FutureOr<List<LocalChatMessage>> build(String roomId) async {
_apiClient = ref.watch(apiClientProvider);
@@ -81,17 +78,11 @@ class MessagesNotifier extends _$MessagesNotifier {
talker.log('MessagesNotifier built for room $roomId');
// Set up disposal handling
ref.onDispose(() {
_disposed = true;
talker.log('MessagesNotifier disposed for room $roomId');
});
// Only setup sync and lifecycle listeners if user is a member
if (identity != null) {
ref.listen(appLifecycleStateProvider, (_, next) {
next.whenData((state) {
if (_disposed) return; // Check disposal before accessing ref
if (!ref.mounted) return; // Check disposal before accessing ref
if (state == AppLifecycleState.paused) {
_lastPauseTime = DateTime.now();
talker.log('App paused, recording time');
@@ -100,7 +91,7 @@ class MessagesNotifier extends _$MessagesNotifier {
final diff = DateTime.now().difference(_lastPauseTime!);
if (diff > const Duration(minutes: 1)) {
talker.log('App resumed after >1 min, syncing messages');
if (!_disposed) {
if (!!ref.mounted) {
syncMessages(); // Check disposal before calling syncMessages
}
} else {
@@ -336,7 +327,7 @@ class MessagesNotifier extends _$MessagesNotifier {
talker.log('Starting message sync');
// Use Future.microtask to set syncing state, but check disposal to avoid errors
Future.microtask(() {
if (!_disposed) {
if (!!ref.mounted) {
ref.read(chatSyncingProvider.notifier).set(true);
}
});
@@ -514,7 +505,7 @@ class MessagesNotifier extends _$MessagesNotifier {
if (!_hasMore || state is AsyncLoading) return;
talker.log('Loading more messages');
if (!_disposed) {
if (!!ref.mounted) {
Future.microtask(() => ref.read(chatSyncingProvider.notifier).set(true));
}
try {
@@ -1058,7 +1049,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_isJumping = true;
// Clear flashing messages when starting a new jump
if (!_disposed) {
if (!!ref.mounted) {
ref.read(flashingMessagesProvider.notifier).state = {};
}

View File

@@ -58,11 +58,18 @@ class ActivityListNotifier
// Find the latest createdAt timestamp from all items for cursor-based pagination
// This ensures we get items created before this timestamp, regardless of sort order
if (items.isNotEmpty) {
final latestCreatedAt = items
final newestCreatedAt = items
.where((e) => e.type.startsWith('posts.'))
.map((e) => e.createdAt)
.reduce((a, b) => a.isBefore(b) ? a : b);
cursor = latestCreatedAt.toUtc().toIso8601String();
if (cursor != null) {
final prevCursor = DateTime.tryParse(cursor!);
if (prevCursor != null && prevCursor.isAfter(newestCreatedAt)) {
cursor = newestCreatedAt.toUtc().toIso8601String();
}
} else {
cursor = newestCreatedAt.toUtc().toIso8601String();
}
}
return items;

View File

@@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/paging.dart';
import 'package:island/pods/userinfo.dart';
@@ -22,7 +23,7 @@ import 'package:island/pods/network.dart';
part 'relationship.g.dart';
@riverpod
Future<List<SnRelationship>> sentFriendRequest(Ref ref) async {
Future<List<SnRelationship>> friendRequest(Ref ref) async {
final client = ref.read(apiClientProvider);
final resp = await client.get('/pass/relationships/requests');
return resp.data
@@ -31,11 +32,9 @@ Future<List<SnRelationship>> sentFriendRequest(Ref ref) async {
.toList();
}
final relationshipListNotifierProvider =
AsyncNotifierProvider.autoDispose<
RelationshipListNotifier,
PaginationState<SnRelationship>
>(RelationshipListNotifier.new);
final relationshipListNotifierProvider = AsyncNotifierProvider.autoDispose(
RelationshipListNotifier.new,
);
class RelationshipListNotifier
extends AsyncNotifier<PaginationState<SnRelationship>>
@@ -84,6 +83,7 @@ class RelationshipListTile extends StatelessWidget {
final String? currentUserId;
final bool showRelatedAccount;
final Function(SnRelationship, int)? onUpdateStatus;
final Function(SnRelationship)? onDelete;
const RelationshipListTile({
super.key,
@@ -96,18 +96,19 @@ class RelationshipListTile extends StatelessWidget {
required this.currentUserId,
this.showRelatedAccount = false,
this.onUpdateStatus,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final account = showRelatedAccount
? relationship.related
: relationship.account;
? relationship.related!
: relationship.account!;
final isPending =
relationship.status == 0 && relationship.relatedId == currentUserId;
final isWaiting =
relationship.status == 0 && relationship.accountId == currentUserId;
final isEstablished = relationship.status == 1 || relationship.status == 2;
final isEstablished = relationship.status != 0;
return ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 12),
@@ -185,21 +186,36 @@ class RelationshipListTile extends StatelessWidget {
itemBuilder: (context) => [
if (relationship.status >= 100) // If friend
PopupMenuItem(
child: ListTile(
leading: const Icon(Symbols.block),
title: Text('blockUser').tr(),
contentPadding: EdgeInsets.zero,
),
onTap: () => onUpdateStatus?.call(relationship, -100),
child: Row(
children: [
const Icon(Symbols.block),
const Gap(12),
Text('blockUser').tr(),
],
),
)
else if (relationship.status <= -100) // If blocked
PopupMenuItem(
child: ListTile(
leading: const Icon(Symbols.person_add),
title: Text('unblockUser').tr(),
contentPadding: EdgeInsets.zero,
),
onTap: () => onUpdateStatus?.call(relationship, 100),
child: Row(
children: [
const Icon(Symbols.person_add),
const Gap(12),
Text('unblockUser').tr(),
],
),
),
if (onDelete != null)
PopupMenuItem(
onTap: () => onDelete?.call(relationship),
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(12),
Text('forgotRelationship').tr(),
],
),
),
],
),
@@ -230,40 +246,11 @@ class RelationshipScreen extends HookConsumerWidget {
final client = ref.read(apiClientProvider);
await client.post('/pass/relationships/${result.id}/friends');
ref.invalidate(sentFriendRequestProvider);
ref.invalidate(friendRequestProvider);
}
final submitting = useState(false);
Future<void> handleFriendRequest(
SnRelationship relationship,
bool isAccept,
) async {
try {
submitting.value = true;
final client = ref.read(apiClientProvider);
await client.post(
'/pass/relationships/${relationship.accountId}/friends/${isAccept ? 'accept' : 'decline'}',
);
relationshipNotifier.refresh();
if (!context.mounted) return;
if (isAccept) {
showSnackBar(
'friendRequestAccepted'.tr(args: ['@${relationship.account.name}']),
);
} else {
showSnackBar(
'friendRequestDeclined'.tr(args: ['@${relationship.account.name}']),
);
}
HapticFeedback.lightImpact();
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
Future<void> updateRelationship(
SnRelationship relationship,
int newStatus,
@@ -276,8 +263,32 @@ class RelationshipScreen extends HookConsumerWidget {
relationshipNotifier.refresh();
}
Future<void> deleteRelationship(SnRelationship relationship) async {
final confirmed = await showConfirmAlert(
'forgotRelationshipConfirm'.tr(
args: ['@${relationship.related!.name}'],
),
'forgotRelationship'.tr(),
isDanger: true,
);
if (!confirmed) return;
if (!context.mounted) return;
showLoadingModal(context);
try {
final client = ref.read(apiClientProvider);
await client.delete('/pass/relationships/${relationship.relatedId}');
relationshipNotifier.refresh();
showSnackBar('relationshipDeleted'.tr());
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
final user = ref.watch(userInfoProvider);
final requests = ref.watch(sentFriendRequestProvider);
final requests = ref.watch(friendRequestProvider);
return AppScaffold(
appBar: AppBar(title: Text('relationships').tr()),
@@ -293,9 +304,9 @@ class RelationshipScreen extends HookConsumerWidget {
if (requests.hasValue && requests.value!.isNotEmpty)
ListTile(
leading: const Icon(Symbols.send),
title: Text('friendSentRequest').tr(),
title: Text('friendRequests').tr(),
subtitle: Text(
'friendSentRequestHint'.plural(requests.value!.length),
'friendRequestsHint'.plural(requests.value!.length),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
@@ -316,11 +327,10 @@ class RelationshipScreen extends HookConsumerWidget {
return RelationshipListTile(
relationship: relationship,
submitting: submitting.value,
onAccept: () => handleFriendRequest(relationship, true),
onDecline: () => handleFriendRequest(relationship, false),
currentUserId: user.value?.id,
showRelatedAccount: false,
showRelatedAccount: true,
onUpdateStatus: updateRelationship,
onDelete: deleteRelationship,
);
},
),
@@ -336,19 +346,54 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final requests = ref.watch(sentFriendRequestProvider);
final requests = ref.watch(friendRequestProvider);
final user = ref.watch(userInfoProvider);
Future<void> cancelRequest(SnRelationship request) async {
try {
final client = ref.read(apiClientProvider);
await client.delete('/pass/relationships/${request.relatedId}/friends');
ref.invalidate(sentFriendRequestProvider);
ref.invalidate(friendRequestProvider);
} catch (err) {
showErrorAlert(err);
}
}
final submitting = useState(false);
Future<void> handleFriendRequest(
SnRelationship relationship,
bool isAccept,
) async {
try {
submitting.value = true;
final client = ref.read(apiClientProvider);
await client.post(
'/pass/relationships/${relationship.accountId}/friends/${isAccept ? 'accept' : 'decline'}',
);
ref.invalidate(friendRequestProvider);
if (!context.mounted) return;
if (isAccept) {
showSnackBar(
'friendRequestAccepted'.tr(
args: ['@${relationship.account!.name}'],
),
);
} else {
showSnackBar(
'friendRequestDeclined'.tr(
args: ['@${relationship.account!.name}'],
),
);
}
HapticFeedback.lightImpact();
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
@@ -366,7 +411,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
child: Row(
children: [
Text(
'friendSentRequest'.tr(),
'friendRequests'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
@@ -377,7 +422,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
icon: const Icon(Symbols.refresh),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
onPressed: () {
ref.invalidate(sentFriendRequestProvider);
ref.invalidate(friendRequestProvider);
},
),
IconButton(
@@ -394,7 +439,7 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
data: (items) => items.isEmpty
? Center(
child: Text(
'friendSentRequestEmpty'.tr(),
'friendRequestsEmpty'.tr(),
textAlign: TextAlign.center,
),
)
@@ -406,6 +451,8 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
return RelationshipListTile(
relationship: request,
onCancel: () => cancelRequest(request),
onAccept: () => handleFriendRequest(request, true),
onDecline: () => handleFriendRequest(request, false),
currentUserId: user.value?.id,
showRelatedAccount: true,
);

View File

@@ -9,10 +9,10 @@ part of 'relationship.dart';
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(sentFriendRequest)
final sentFriendRequestProvider = SentFriendRequestProvider._();
@ProviderFor(friendRequest)
final friendRequestProvider = FriendRequestProvider._();
final class SentFriendRequestProvider
final class FriendRequestProvider
extends
$FunctionalProvider<
AsyncValue<List<SnRelationship>>,
@@ -22,19 +22,19 @@ final class SentFriendRequestProvider
with
$FutureModifier<List<SnRelationship>>,
$FutureProvider<List<SnRelationship>> {
SentFriendRequestProvider._()
FriendRequestProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sentFriendRequestProvider',
name: r'friendRequestProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sentFriendRequestHash();
String debugGetCreateSourceHash() => _$friendRequestHash();
@$internal
@override
@@ -44,8 +44,8 @@ final class SentFriendRequestProvider
@override
FutureOr<List<SnRelationship>> create(Ref ref) {
return sentFriendRequest(ref);
return friendRequest(ref);
}
}
String _$sentFriendRequestHash() => r'0c52813eb6f86c05f6e0b1e4e840d0d9c350aa9e';
String _$friendRequestHash() => r'b066553f9bd0505a7a272cf10cb3ca312152acd6';

View File

@@ -317,7 +317,7 @@ class ExploreScreen extends HookConsumerWidget {
// Sliver list cannot provide refresh handled by the pagination list
isRefreshable: false,
isSliver: true,
footerSkeletonChild: const PostItemSkeleton(),
footerSkeletonChild: const PostItemSkeleton(maxWidth: double.infinity),
contentBuilder: (data, footer) =>
_ActivityListView(data: data, isWide: isWide, footer: footer),
);

View File

@@ -9,8 +9,10 @@ import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_file_collection.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/post/post_award_sheet.dart';
import 'package:island/widgets/post/post_item.dart';
@@ -19,6 +21,7 @@ import 'package:island/widgets/post/post_pin_sheet.dart';
import 'package:island/widgets/post/post_quick_reply.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/widgets/post/post_replies.dart';
import 'package:island/widgets/post/post_shared.dart';
import 'package:island/widgets/response.dart';
import 'package:island/utils/share_utils.dart';
import 'package:island/widgets/safety/abuse_report_helper.dart';
@@ -62,6 +65,10 @@ class PostState extends Notifier<AsyncValue<SnPost?>> {
}
}
bool _isMediaPost(SnPost? post) {
return post != null && post.type == 0 && post.attachments.isNotEmpty;
}
class PostActionButtons extends HookConsumerWidget {
final SnPost post;
final EdgeInsets renderingPadding;
@@ -435,6 +442,88 @@ class PostActionButtons extends HookConsumerWidget {
}
}
class _PostDetailLargeScreenLayout extends StatelessWidget {
final SnPost post;
final WidgetRef ref;
final String postId;
final Function(SnPost) onUpdate;
final VoidCallback onRefresh;
const _PostDetailLargeScreenLayout({
required this.post,
required this.ref,
required this.postId,
required this.onUpdate,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
flex: 3,
child: Material(
color: Theme.of(context).cardTheme.color,
elevation: 8,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: CloudFileList(
files: post.attachments,
disableConstraint: true,
padding: EdgeInsets.zero,
),
),
),
),
),
Expanded(
flex: 2,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PostHeader(
item: post,
isFullPost: true,
isCompact: false,
renderingPadding: EdgeInsets.zero,
),
const Gap(8),
PostBody(
item: post,
isFullPost: true,
isTextSelectable: true,
renderingPadding: EdgeInsets.zero,
hideAttachments: true,
textScale: post.type == 1 ? 1.2 : 1.1,
),
const Gap(12),
PostActionButtons(
post: post,
renderingPadding: EdgeInsets.zero,
onRefresh: onRefresh,
onUpdate: onUpdate,
),
],
),
),
),
PostRepliesList(postId: postId, maxWidth: 800),
SliverGap(MediaQuery.of(context).padding.bottom + 80),
],
),
),
],
);
}
}
class PostDetailScreen extends HookConsumerWidget {
final String id;
const PostDetailScreen({super.key, required this.id});
@@ -452,6 +541,8 @@ class PostDetailScreen extends HookConsumerWidget {
),
body: postState.when(
data: (post) {
final isMediaPostLayout = isWideScreen(context) && _isMediaPost(post);
return Stack(
fit: StackFit.expand,
children: [
@@ -460,64 +551,78 @@ class PostDetailScreen extends HookConsumerWidget {
ref.invalidate(postProvider(id));
ref.read(postRepliesProvider(id).notifier).refresh();
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 800),
child: PostItem(
item: post!,
isFullPost: true,
isEmbedReply: false,
textScale: post.type == 1 ? 1.2 : 1.1,
onUpdate: (newItem) {
// Update the local state with the new post data
ref
.read(postStateProvider(id).notifier)
.updatePost(newItem);
},
),
),
),
),
SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 800),
child: PostActionButtons(
post: post,
renderingPadding: const EdgeInsets.symmetric(
horizontal: 8,
child: isMediaPostLayout
? _PostDetailLargeScreenLayout(
post: post!,
ref: ref,
postId: id,
onUpdate: (newItem) {
ref
.read(postStateProvider(id).notifier)
.updatePost(newItem);
},
onRefresh: () {
ref.invalidate(postProvider(id));
ref.read(postRepliesProvider(id).notifier).refresh();
},
)
: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 800),
child: PostItem(
item: post!,
isFullPost: true,
isEmbedReply: false,
textScale: post.type == 1 ? 1.2 : 1.1,
onUpdate: (newItem) {
ref
.read(postStateProvider(id).notifier)
.updatePost(newItem);
},
),
),
),
onRefresh: () {
ref.invalidate(postProvider(id));
ref
.read(postRepliesProvider(id).notifier)
.refresh();
},
onUpdate: (newItem) {
ref
.read(postStateProvider(id).notifier)
.updatePost(newItem);
},
),
),
SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 800),
child: PostActionButtons(
post: post,
renderingPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
onRefresh: () {
ref.invalidate(postProvider(id));
ref
.read(postRepliesProvider(id).notifier)
.refresh();
},
onUpdate: (newItem) {
ref
.read(postStateProvider(id).notifier)
.updatePost(newItem);
},
),
),
),
),
PostRepliesList(postId: id, maxWidth: 800),
SliverGap(MediaQuery.of(context).padding.bottom + 80),
],
),
),
PostRepliesList(postId: id, maxWidth: 800),
SliverGap(MediaQuery.of(context).padding.bottom + 80),
],
),
),
if (user.value != null)
if (user.value != null && !isMediaPostLayout)
Positioned(
bottom: 16 + MediaQuery.of(context).padding.bottom,
left: 16,
right: 16,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 660),
constraints: BoxConstraints(maxWidth: 800),
child: postState.when(
data: (post) => PostQuickReply(
parent: post!,

View File

@@ -23,6 +23,7 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/activity_heatmap.dart';
import 'package:island/widgets/posts/post_filter.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -32,6 +33,113 @@ import 'package:styled_widget/styled_widget.dart';
part 'publisher_profile.g.dart';
class _PinnedPostsPageView extends HookConsumerWidget {
final String pubName;
const _PinnedPostsPageView({required this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = postListProvider(
PostListQueryConfig(
id: 'publisher-$pubName-pinned',
initialFilter: PostListQuery(pubName: pubName, pinned: true),
),
);
final pinnedPosts = ref.watch(provider);
final pageController = usePageController();
final currentPage = useState(0);
useEffect(() {
void listener() {
currentPage.value = pageController.page?.round() ?? 0;
}
pageController.addListener(listener);
return () => pageController.removeListener(listener);
}, [pageController]);
return pinnedPosts.when(
data: (data) {
if (data.items.isEmpty) {
return const SizedBox.shrink();
}
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: true,
leading: const Icon(Symbols.push_pin),
title: Text('pinnedPosts'.tr()),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
collapsedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
children: [
SizedBox(
height: 400,
child: Stack(
children: [
PageView.builder(
controller: pageController,
itemCount: data.items.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8),
child: SingleChildScrollView(
child: Card(
child: PostActionableItem(
item: data.items[index],
borderRadius: 8,
),
),
),
);
},
),
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
data.items.length,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: EdgeInsets.symmetric(horizontal: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == currentPage.value
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.primary.withOpacity(0.5),
),
),
),
),
),
],
),
),
],
),
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
);
}
}
class _PublisherBasisWidget extends StatelessWidget {
final SnPublisher data;
final AsyncValue<SnPublisherSubscription?> subStatus;
@@ -431,7 +539,6 @@ class PublisherProfileScreen extends HookConsumerWidget {
final queryState = useState(PostListQuery(pubName: name));
final subscribing = useState(false);
final isPinnedExpanded = useState(true);
useEffect(() {
final index = switch (queryState.value.type) {
@@ -510,36 +617,8 @@ class PublisherProfileScreen extends HookConsumerWidget {
slivers: [
SliverGap(16),
SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: ListTile(
title: Text('pinnedPosts'.tr()),
leading: const Icon(Symbols.push_pin),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
trailing: Icon(
isPinnedExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap: () => isPinnedExpanded.value =
!isPinnedExpanded.value,
),
),
child: _PinnedPostsPageView(pubName: name),
),
...[
if (isPinnedExpanded.value)
SliverPostList(
query: PostListQuery(pubName: name, pinned: true),
queryKey: 'publisher-$name-pinned',
),
],
SliverToBoxAdapter(
child: PostFilterWidget(
categoryTabController: categoryTabController,
@@ -644,27 +723,8 @@ class PublisherProfileScreen extends HookConsumerWidget {
).padding(vertical: 4),
),
SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
title: Text('pinnedPosts'.tr()),
trailing: Icon(
isPinnedExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap: () =>
isPinnedExpanded.value = !isPinnedExpanded.value,
),
),
child: _PinnedPostsPageView(pubName: name),
),
...[
if (isPinnedExpanded.value)
SliverPostList(
query: PostListQuery(pubName: name, pinned: true),
queryKey: 'publisher-$name-pinned',
),
],
SliverToBoxAdapter(
child: PostFilterWidget(
categoryTabController: categoryTabController,

View File

@@ -9,6 +9,7 @@ import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_pfc.dart';
import 'package:island/widgets/account/status.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/services/color_extraction.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
@@ -27,6 +28,113 @@ import 'package:island/pods/paging.dart';
import 'package:island/widgets/paging/pagination_list.dart';
import 'package:styled_widget/styled_widget.dart';
class _RealmPinnedPostsPageView extends HookConsumerWidget {
final String realmSlug;
const _RealmPinnedPostsPageView({required this.realmSlug});
@override
Widget build(BuildContext context, WidgetRef ref) {
final provider = postListProvider(
PostListQueryConfig(
id: 'realm-$realmSlug-pinned',
initialFilter: PostListQuery(realm: realmSlug, pinned: true),
),
);
final pinnedPosts = ref.watch(provider);
final pageController = usePageController();
final currentPage = useState(0);
useEffect(() {
void listener() {
currentPage.value = pageController.page?.round() ?? 0;
}
pageController.addListener(listener);
return () => pageController.removeListener(listener);
}, [pageController]);
return pinnedPosts.when(
data: (data) {
if (data.items.isEmpty) {
return const SizedBox.shrink();
}
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: true,
leading: const Icon(Symbols.push_pin),
title: Text('pinnedPosts'.tr()),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
collapsedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
children: [
SizedBox(
height: 400,
child: Stack(
children: [
PageView.builder(
controller: pageController,
itemCount: data.items.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8),
child: SingleChildScrollView(
child: Card(
child: PostActionableItem(
item: data.items[index],
borderRadius: 8,
),
),
),
);
},
),
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
data.items.length,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: EdgeInsets.symmetric(horizontal: 4),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == currentPage.value
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.primary.withOpacity(0.5),
),
),
),
),
),
],
),
),
],
),
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
);
}
}
final realmAppbarForegroundColorProvider = FutureProvider.autoDispose
.family<Color?, String>((ref, realmSlug) async {
final realm = await ref.watch(realmProvider(realmSlug).future);
@@ -235,8 +343,8 @@ class RealmDetailScreen extends HookConsumerWidget {
flex: 3,
child: CustomScrollView(
slivers: [
SliverPostList(
query: PostListQuery(realm: slug, pinned: true),
SliverToBoxAdapter(
child: _RealmPinnedPostsPageView(realmSlug: slug),
),
SliverPostList(
query: PostListQuery(realm: slug, pinned: false),
@@ -338,8 +446,8 @@ class RealmDetailScreen extends HookConsumerWidget {
),
),
SliverToBoxAdapter(child: realmChatRoomListWidget(realm)),
SliverPostList(
query: PostListQuery(realm: slug, pinned: true),
SliverToBoxAdapter(
child: _RealmPinnedPostsPageView(realmSlug: slug),
),
SliverPostList(
query: PostListQuery(realm: slug, pinned: false),

View File

@@ -167,7 +167,9 @@ class _PostsSearchTab extends HookConsumerWidget {
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: const PostItemSkeleton(),
child: const PostItemSkeleton(
maxWidth: double.infinity,
),
),
itemBuilder: (context, index, post) {
return Card(
@@ -320,7 +322,7 @@ class _PostsSearchTab extends HookConsumerWidget {
isRefreshable: false,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: const PostItemSkeleton(),
child: const PostItemSkeleton(maxWidth: double.infinity),
),
itemBuilder: (context, index, post) {
return Center(

View File

@@ -0,0 +1 @@
export 'app_intents/ios.dart';

View File

@@ -0,0 +1,443 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_app_intents/flutter_app_intents.dart';
import 'package:go_router/go_router.dart';
import 'package:island/models/auth.dart';
import 'package:island/pods/config.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:island/talker.dart';
import 'package:island/route.dart';
class AppIntentsService {
static final AppIntentsService _instance = AppIntentsService._internal();
factory AppIntentsService() => _instance;
AppIntentsService._internal();
FlutterAppIntentsClient? _client;
bool _initialized = false;
Dio? _dio;
Future<void> initialize() async {
if (!Platform.isIOS) {
talker.warning('[AppIntents] App Intents only supported on iOS');
return;
}
if (_initialized) {
talker.info('[AppIntents] Already initialized');
return;
}
try {
talker.info('[AppIntents] Initializing App Intents client...');
_client = FlutterAppIntentsClient.instance;
// Initialize Dio for API calls
final prefs = await SharedPreferences.getInstance();
final serverUrl =
prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
final tokenString = prefs.getString(kTokenPairStoreKey);
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
};
if (tokenString != null) {
try {
final token = AppToken.fromJson(jsonDecode(tokenString));
headers['Authorization'] = 'AtField ${token.token}';
} catch (e) {
talker.warning('[AppIntents] Failed to parse token: $e');
}
}
_dio = Dio(
BaseOptions(
baseUrl: serverUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: headers,
),
);
await _registerIntents();
_initialized = true;
talker.info('[AppIntents] All intents registered successfully');
} catch (e, stack) {
talker.error('[AppIntents] Initialization failed', e, stack);
rethrow;
}
}
Future<void> _registerIntents() async {
if (_client == null) {
throw StateError('Client not initialized');
}
// Navigation Intents
await _client!.registerIntent(
AppIntentBuilder()
.identifier('open_chat')
.title('Open Chat')
.description('Open a specific chat room')
.parameter(
const AppIntentParameter(
name: 'channelId',
title: 'Channel ID',
type: AppIntentParameterType.string,
isOptional: false,
),
)
.build(),
_handleOpenChatIntent,
);
await _client!.registerIntent(
AppIntentBuilder()
.identifier('open_post')
.title('Open Post')
.description('Open a specific post')
.parameter(
const AppIntentParameter(
name: 'postId',
title: 'Post ID',
type: AppIntentParameterType.string,
isOptional: false,
),
)
.build(),
_handleOpenPostIntent,
);
await _client!.registerIntent(
AppIntentBuilder()
.identifier('open_compose')
.title('Open Compose')
.description('Open compose post screen')
.build(),
_handleOpenComposeIntent,
);
// Action Intent
await _client!.registerIntent(
AppIntentBuilder()
.identifier('compose_post')
.title('Compose Post')
.description('Create a new post')
.build(),
_handleComposePostIntent,
);
// Query Intents
await _client!.registerIntent(
AppIntentBuilder()
.identifier('search_content')
.title('Search Content')
.description('Search for content')
.parameter(
const AppIntentParameter(
name: 'query',
title: 'Search Query',
type: AppIntentParameterType.string,
isOptional: false,
),
)
.build(),
_handleSearchContentIntent,
);
await _client!.registerIntent(
AppIntentBuilder()
.identifier('view_notifications')
.title('View Notifications')
.description('View notifications')
.build(),
_handleViewNotificationsIntent,
);
await _client!.registerIntent(
AppIntentBuilder()
.identifier('check_notifications')
.title('Check Notifications')
.description('Check notification count')
.build(),
_handleCheckNotificationsIntent,
);
}
void dispose() {
_client = null;
_initialized = false;
}
Future<AppIntentResult> _handleOpenChatIntent(
Map<String, dynamic> parameters,
) async {
try {
final channelId = parameters['channelId'] as String?;
if (channelId == null) {
throw ArgumentError('channelId is required');
}
talker.info('[AppIntents] Opening chat: $channelId');
if (rootNavigatorKey.currentContext == null) {
return AppIntentResult.failed(error: 'App context not available');
}
rootNavigatorKey.currentContext!.push('/chat/$channelId');
return AppIntentResult.successful(
value: 'Opening chat $channelId',
needsToContinueInApp: true,
);
} catch (e, stack) {
talker.error('[AppIntents] Failed to open chat', e, stack);
return AppIntentResult.failed(error: 'Failed to open chat: $e');
}
}
Future<AppIntentResult> _handleOpenPostIntent(
Map<String, dynamic> parameters,
) async {
try {
final postId = parameters['postId'] as String?;
if (postId == null) {
throw ArgumentError('postId is required');
}
talker.info('[AppIntents] Opening post: $postId');
if (rootNavigatorKey.currentContext == null) {
return AppIntentResult.failed(error: 'App context not available');
}
rootNavigatorKey.currentContext!.push('/posts/$postId');
return AppIntentResult.successful(
value: 'Opening post $postId',
needsToContinueInApp: true,
);
} catch (e, stack) {
talker.error('[AppIntents] Failed to open post', e, stack);
return AppIntentResult.failed(error: 'Failed to open post: $e');
}
}
Future<AppIntentResult> _handleOpenComposeIntent(
Map<String, dynamic> parameters,
) async {
try {
talker.info('[AppIntents] Opening compose screen');
if (rootNavigatorKey.currentContext == null) {
return AppIntentResult.failed(error: 'App context not available');
}
rootNavigatorKey.currentContext!.push('/posts/compose');
return AppIntentResult.successful(
value: 'Opening compose screen',
needsToContinueInApp: true,
);
} catch (e, stack) {
talker.error('[AppIntents] Failed to open compose', e, stack);
return AppIntentResult.failed(error: 'Failed to open compose: $e');
}
}
Future<AppIntentResult> _handleComposePostIntent(
Map<String, dynamic> parameters,
) async {
try {
talker.info('[AppIntents] Composing new post');
if (rootNavigatorKey.currentContext == null) {
return AppIntentResult.failed(error: 'App context not available');
}
rootNavigatorKey.currentContext!.push('/posts/compose');
return AppIntentResult.successful(
value: 'Opening compose screen',
needsToContinueInApp: true,
);
} catch (e, stack) {
talker.error('[AppIntents] Failed to compose post', e, stack);
return AppIntentResult.failed(error: 'Failed to compose post: $e');
}
}
Future<AppIntentResult> _handleSearchContentIntent(
Map<String, dynamic> parameters,
) async {
try {
final query = parameters['query'] as String?;
if (query == null) {
throw ArgumentError('query is required');
}
talker.info('[AppIntents] Searching for: $query');
if (rootNavigatorKey.currentContext == null) {
return AppIntentResult.failed(error: 'App context not available');
}
rootNavigatorKey.currentContext!.push('/search?q=$query');
return AppIntentResult.successful(
value: 'Searching for "$query"',
needsToContinueInApp: true,
);
} catch (e, stack) {
talker.error('[AppIntents] Failed to search', e, stack);
return AppIntentResult.failed(error: 'Failed to search: $e');
}
}
Future<AppIntentResult> _handleViewNotificationsIntent(
Map<String, dynamic> parameters,
) async {
try {
talker.info('[AppIntents] Opening notifications');
if (rootNavigatorKey.currentContext == null) {
return AppIntentResult.failed(error: 'App context not available');
}
// Note: You may need to adjust the route based on your actual notifications route
// This is a common pattern - check your route.dart for exact path
// If you don't have a dedicated notifications route, you might need to add one
return AppIntentResult.failed(
error: 'Notifications route not implemented',
);
} catch (e, stack) {
talker.error('[AppIntents] Failed to view notifications', e, stack);
return AppIntentResult.failed(error: 'Failed to view notifications: $e');
}
}
Future<AppIntentResult> _handleCheckNotificationsIntent(
Map<String, dynamic> parameters,
) async {
try {
talker.info('[AppIntents] Checking notifications count');
if (_dio == null) {
return AppIntentResult.failed(error: 'API client not initialized');
}
try {
final response = await _dio!.get('/ring/notifications/count');
final count = (response.data as num).toInt();
final countValue = count;
String message;
if (countValue == 0) {
message = 'You have no new notifications';
} else if (countValue == 1) {
message = 'You have 1 new notification';
} else {
message = 'You have $countValue new notifications';
}
return AppIntentResult.successful(
value: message,
needsToContinueInApp: false,
);
} on DioException catch (e) {
talker.error('[AppIntents] API error checking notifications', e);
return AppIntentResult.failed(
error:
'Failed to fetch notifications: ${e.message ?? 'Network error'}',
);
}
} catch (e, stack) {
talker.error('[AppIntents] Failed to check notifications', e, stack);
return AppIntentResult.failed(error: 'Failed to check notifications: $e');
}
}
// Donation Methods - to be called manually from your app code
Future<void> donateOpenChat(String channelId) async {
if (!_initialized) return;
try {
await FlutterAppIntentsService.donateIntentWithMetadata(
'open_chat',
{'channelId': channelId},
relevanceScore: 0.8,
context: {'feature': 'chat', 'userAction': true},
);
talker.info('[AppIntents] Donated open_chat intent');
} catch (e, stack) {
talker.error('[AppIntents] Failed to donate open_chat', e, stack);
}
}
Future<void> donateOpenPost(String postId) async {
if (!_initialized) return;
try {
await FlutterAppIntentsService.donateIntentWithMetadata(
'open_post',
{'postId': postId},
relevanceScore: 0.8,
context: {'feature': 'posts', 'userAction': true},
);
talker.info('[AppIntents] Donated open_post intent');
} catch (e, stack) {
talker.error('[AppIntents] Failed to donate open_post', e, stack);
}
}
Future<void> donateCompose() async {
if (!_initialized) return;
try {
await FlutterAppIntentsService.donateIntentWithMetadata(
'open_compose',
{},
relevanceScore: 0.9,
context: {'feature': 'compose', 'userAction': true},
);
talker.info('[AppIntents] Donated compose intent');
} catch (e, stack) {
talker.error('[AppIntents] Failed to donate compose', e, stack);
}
}
Future<void> donateSearch(String query) async {
if (!_initialized) return;
try {
await FlutterAppIntentsService.donateIntentWithMetadata(
'search_content',
{'query': query},
relevanceScore: 0.7,
context: {'feature': 'search', 'userAction': true},
);
talker.info('[AppIntents] Donated search intent');
} catch (e, stack) {
talker.error('[AppIntents] Failed to donate search', e, stack);
}
}
Future<void> donateCheckNotifications() async {
if (!_initialized) return;
try {
await FlutterAppIntentsService.donateIntentWithMetadata(
'check_notifications',
{},
relevanceScore: 0.6,
context: {'feature': 'notifications', 'userAction': true},
);
talker.info('[AppIntents] Donated check_notifications intent');
} catch (e, stack) {
talker.error(
'[AppIntents] Failed to donate check_notifications',
e,
stack,
);
}
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:island/route.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/talker.dart';
import 'package:quick_actions/quick_actions.dart';
class QuickActionsService {
static final QuickActionsService _instance = QuickActionsService._internal();
factory QuickActionsService() => _instance;
QuickActionsService._internal();
final QuickActions _quickActions = const QuickActions();
bool _initialized = false;
Future<void> initialize() async {
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
talker.warning(
'[QuickActions] Quick Actions only supported on Android and iOS',
);
return;
}
if (_initialized) {
talker.info('[QuickActions] Already initialized');
return;
}
try {
talker.info('[QuickActions] Initializing Quick Actions...');
// TODO Add icons for these
final shortcuts = <ShortcutItem>[
const ShortcutItem(type: 'compose_post', localizedTitle: 'New Post'),
const ShortcutItem(type: 'explore', localizedTitle: 'Explore'),
const ShortcutItem(type: 'chats', localizedTitle: 'Chats'),
const ShortcutItem(
type: 'notifications',
localizedTitle: 'Notifications',
),
];
await _quickActions.initialize(_handleShortcut);
await _quickActions.setShortcutItems(shortcuts);
_initialized = true;
talker.info('[QuickActions] Quick Actions initialized successfully');
} catch (e, stack) {
talker.error('[QuickActions] Initialization failed', e, stack);
rethrow;
}
}
void _handleShortcut(String type) {
talker.info('[QuickActions] Shortcut tapped: $type');
final context = rootNavigatorKey.currentContext;
if (context == null) {
talker.warning('[QuickActions] Context not available, skipping action');
return;
}
switch (type) {
case 'compose_post':
eventBus.fire(const ShowComposeSheetEvent());
break;
case 'explore':
context.go('/explore');
break;
case 'chats':
context.go('/chat');
break;
case 'notifications':
context.go('/notifications');
break;
default:
talker.warning('[QuickActions] Unknown shortcut type: $type');
}
}
void dispose() {
_initialized = false;
}
}

View File

@@ -27,200 +27,198 @@ class AccountProfileCard extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final account = ref.watch(accountProvider(uname));
final width =
math.min(MediaQuery.of(context).size.width - 80, 360).toDouble();
final width = math
.min(MediaQuery.of(context).size.width - 80, 360)
.toDouble();
final child = account.when(
data: (data) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (data.profile.background != null)
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: CloudImageWidget(file: data.profile.background),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
GestureDetector(
child: Badge(
isLabelVisible: true,
padding: EdgeInsets.all(2),
label: Icon(
Symbols.launch,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
backgroundColor: Theme.of(context).colorScheme.primary,
offset: Offset(4, 28),
child: ProfilePictureWidget(file: data.profile.picture),
),
onTap: () {
Navigator.pop(context);
context.pushNamed(
'accountProfile',
pathParameters: {'name': data.name},
);
},
),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountName(
account: data,
style: TextStyle(fontWeight: FontWeight.bold),
),
Text('@${data.name}').fontSize(12),
],
),
),
],
),
const Gap(12),
AccountStatusWidget(uname: data.name, padding: EdgeInsets.zero),
Tooltip(
message: 'creditsStatus'.tr(),
child: Row(
spacing: 6,
children: [
Icon(
Symbols.attribution,
size: 17,
fill: 1,
).padding(right: 2),
Text(
'${data.profile.socialCredits.toStringAsFixed(2)} pts',
).fontSize(12),
switch (data.profile.socialCreditsLevel) {
-1 => Text('socialCreditsLevelPoor').tr(),
0 => Text('socialCreditsLevelNormal').tr(),
1 => Text('socialCreditsLevelGood').tr(),
2 => Text('socialCreditsLevelExcellent').tr(),
_ => Text('unknown').tr(),
}.fontSize(12),
],
),
),
if (data.automatedId != null)
Row(
spacing: 6,
children: [
Icon(
Symbols.smart_toy,
size: 17,
fill: 1,
).padding(right: 2),
Text('accountAutomated').tr().fontSize(12),
],
),
if (data.profile.timeZone.isNotEmpty && !kIsWeb)
() {
try {
final tzInfo = getTzInfo(data.profile.timeZone);
return Row(
spacing: 6,
children: [
Icon(
Symbols.alarm,
size: 17,
fill: 1,
).padding(right: 2),
Text(
tzInfo.$2.formatCustomGlobal('HH:mm'),
).fontSize(12),
Text(tzInfo.$1.formatOffsetLocal()).fontSize(12),
],
).padding(top: 2);
} catch (e) {
return Row(
spacing: 6,
children: [
Icon(
Symbols.alarm,
size: 17,
fill: 1,
).padding(right: 2),
Text('timezoneNotFound'.tr()).fontSize(12),
],
).padding(top: 2);
}
}(),
Row(
spacing: 6,
children: [
Icon(Symbols.stairs, size: 17, fill: 1).padding(right: 2),
Text(
'levelingProgressLevel'.tr(
args: [data.profile.level.toString()],
),
).fontSize(12),
Expanded(
child: Tooltip(
message:
'${(data.profile.levelingProgress * 100).toStringAsFixed(2)}%',
child: LinearProgressIndicator(
value: data.profile.levelingProgress,
stopIndicatorRadius: 0,
trackGap: 0,
minHeight: 4,
).padding(top: 1),
),
),
],
).padding(top: 2),
if (data.badges.isNotEmpty)
BadgeList(badges: data.badges).padding(top: 12),
ActivityPresenceWidget(
uname: uname,
isCompact: true,
compactPadding: const EdgeInsets.only(top: 12),
),
],
).padding(horizontal: 24, vertical: 16),
],
),
error: (err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(accountProvider(uname)),
),
loading: () => SizedBox(
width: width,
height: width,
child: Padding(
padding: const EdgeInsets.all(24),
child: CircularProgressIndicator(),
).center(),
),
);
return PopupCard(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
child: SizedBox(
width: width,
child: account.when(
data:
(data) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (data.profile.background != null)
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: CloudImageWidget(file: data.profile.background),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
GestureDetector(
child: Badge(
isLabelVisible: true,
padding: EdgeInsets.all(2),
label: Icon(
Symbols.launch,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
backgroundColor:
Theme.of(context).colorScheme.primary,
offset: Offset(4, 28),
child: ProfilePictureWidget(
file: data.profile.picture,
),
),
onTap: () {
Navigator.pop(context);
context.pushNamed(
'accountProfile',
pathParameters: {'name': data.name},
);
},
),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountName(
account: data,
style: TextStyle(fontWeight: FontWeight.bold),
),
Text('@${data.name}').fontSize(12),
],
),
),
],
),
const Gap(12),
AccountStatusWidget(
uname: data.name,
padding: EdgeInsets.zero,
),
Tooltip(
message: 'creditsStatus'.tr(),
child: Row(
spacing: 6,
children: [
Icon(
Symbols.attribution,
size: 17,
fill: 1,
).padding(right: 2),
Text(
'${data.profile.socialCredits.toStringAsFixed(2)} pts',
).fontSize(12),
switch (data.profile.socialCreditsLevel) {
-1 => Text('socialCreditsLevelPoor').tr(),
0 => Text('socialCreditsLevelNormal').tr(),
1 => Text('socialCreditsLevelGood').tr(),
2 => Text('socialCreditsLevelExcellent').tr(),
_ => Text('unknown').tr(),
}.fontSize(12),
],
),
),
if (data.automatedId != null)
Row(
spacing: 6,
children: [
Icon(
Symbols.smart_toy,
size: 17,
fill: 1,
).padding(right: 2),
Text('accountAutomated').tr().fontSize(12),
],
),
if (data.profile.timeZone.isNotEmpty && !kIsWeb)
() {
try {
final tzInfo = getTzInfo(data.profile.timeZone);
return Row(
spacing: 6,
children: [
Icon(
Symbols.alarm,
size: 17,
fill: 1,
).padding(right: 2),
Text(
tzInfo.$2.formatCustomGlobal('HH:mm'),
).fontSize(12),
Text(
tzInfo.$1.formatOffsetLocal(),
).fontSize(12),
],
).padding(top: 2);
} catch (e) {
return Row(
spacing: 6,
children: [
Icon(
Symbols.alarm,
size: 17,
fill: 1,
).padding(right: 2),
Text('timezoneNotFound'.tr()).fontSize(12),
],
).padding(top: 2);
}
}(),
Row(
spacing: 6,
children: [
Icon(
Symbols.stairs,
size: 17,
fill: 1,
).padding(right: 2),
Text(
'levelingProgressLevel'.tr(
args: [data.profile.level.toString()],
),
).fontSize(12),
Expanded(
child: Tooltip(
message:
'${(data.profile.levelingProgress * 100).toStringAsFixed(2)}%',
child: LinearProgressIndicator(
value: data.profile.levelingProgress,
stopIndicatorRadius: 0,
trackGap: 0,
minHeight: 4,
).padding(top: 1),
),
),
],
).padding(top: 2),
if (data.badges.isNotEmpty)
BadgeList(badges: data.badges).padding(top: 12),
ActivityPresenceWidget(
uname: uname,
isCompact: true,
compactPadding: const EdgeInsets.only(top: 12),
),
],
).padding(horizontal: 24, vertical: 16),
],
),
error:
(err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(accountProvider(uname)),
),
loading:
() => SizedBox(
width: width,
height: width,
child:
Padding(
padding: const EdgeInsets.all(24),
child: CircularProgressIndicator(),
).center(),
),
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: SizedBox(
width: width,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) {
return FadeTransition(opacity: animation, child: child);
},
child: child,
),
),
),
);

View File

@@ -357,44 +357,34 @@ class AppScaffold extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final focusNode = useFocusNode();
useEffect(() {
focusNode.requestFocus();
return null;
}, []);
final appBarHeight = appBar?.preferredSize.height ?? 0;
final safeTop = MediaQuery.of(context).padding.top;
final noBackground = isNoBackground ?? isWideScreen(context);
final builtWidget = Focus(
focusNode: focusNode,
child: Scaffold(
extendBody: extendBody ?? true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.transparent,
body: Column(
children: [
IgnorePointer(
child: SizedBox(
height: appBar != null ? appBarHeight + safeTop : 0,
),
final builtWidget = Scaffold(
extendBody: extendBody ?? true,
extendBodyBehindAppBar: true,
backgroundColor: Colors.transparent,
body: Column(
children: [
IgnorePointer(
child: SizedBox(
height: appBar != null ? appBarHeight + safeTop : 0,
),
if (body != null) Expanded(child: body!),
],
),
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
bottomSheet: bottomSheet,
drawer: drawer,
endDrawer: endDrawer,
floatingActionButton: floatingActionButton,
floatingActionButtonAnimator: floatingActionButtonAnimator,
onDrawerChanged: onDrawerChanged,
onEndDrawerChanged: onEndDrawerChanged,
),
if (body != null) Expanded(child: body!),
],
),
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
bottomSheet: bottomSheet,
drawer: drawer,
endDrawer: endDrawer,
floatingActionButton: floatingActionButton,
floatingActionButtonAnimator: floatingActionButtonAnimator,
onDrawerChanged: onDrawerChanged,
onEndDrawerChanged: onEndDrawerChanged,
);
return noBackground

View File

@@ -759,7 +759,6 @@ class SplitAvatarWidget extends ConsumerWidget {
if (file == null) {
return Container(
width: radius,
height: radius,
color: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
fallbackIcon,
@@ -775,7 +774,6 @@ class SplitAvatarWidget extends ConsumerWidget {
return SizedBox(
width: radius,
height: radius,
child: UniversalImage(uri: uri, fit: BoxFit.cover),
);
}

View File

@@ -75,7 +75,9 @@ class UniversalImage extends HookWidget {
);
},
imageBuilder: (context, imageProvider) {
Future(() => loaded.value = true);
Future(() {
if (context.mounted) return loaded.value = true;
});
return AnimatedOpacity(
opacity: loaded.value ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),

View File

@@ -2,14 +2,16 @@ import 'package:collection/collection.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_highlight/themes/a11y-dark.dart';
import 'package:flutter_highlight/themes/a11y-light.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
import 'package:island/models/file.dart';
import 'package:island/models/publisher.dart';
import 'package:island/pods/config.dart';
import 'package:island/screens/account/profile.dart';
import 'package:island/screens/posts/publisher_profile.dart';
@@ -246,7 +248,7 @@ class MarkdownTextContent extends HookConsumerWidget {
return MarkdownGenerator(
generators: [latexGenerator, ...generators],
inlineSyntaxList: [
_MetionInlineSyntax(),
_MentionInlineSyntax(),
_HighlightInlineSyntax(),
_SpoilerInlineSyntax(),
_StickerInlineSyntax(),
@@ -259,19 +261,24 @@ class MarkdownTextContent extends HookConsumerWidget {
}
}
class _MetionInlineSyntax extends markdown.InlineSyntax {
_MetionInlineSyntax() : super(r'@[-a-zA-Z0-9_./]+');
class _MentionInlineSyntax extends markdown.InlineSyntax {
_MentionInlineSyntax() : super(r'(^|[^A-Za-z0-9._%+\-])(@[-A-Za-z0-9_./]+)');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final alias = match[0]!;
final prefix = match[1] ?? '';
final alias = match[2]!;
if (prefix.isNotEmpty) {
parser.addNode(markdown.Text(prefix));
}
final parts = alias.substring(1).split('/');
final typeShortcut = parts.length == 1 ? 'u' : parts.first;
final type = switch (typeShortcut) {
'u' => 'accounts',
'r' => 'realms',
'p' => 'publishers',
"c" => 'chat',
_ => '',
};
final element = markdown.Element('mention-chip', [markdown.Text(alias)])
@@ -374,6 +381,130 @@ class MentionChipGenerator extends SpanNodeGeneratorWithTag {
);
}
class _MentionChipContent extends HookConsumerWidget {
final String mentionType;
final String id;
final String alias;
final Color backgroundColor;
final Color foregroundColor;
final VoidCallback onTap;
const _MentionChipContent({
required this.mentionType,
required this.id,
required this.alias,
required this.backgroundColor,
required this.foregroundColor,
required this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isHovered = useState(false);
if (mentionType == 'accounts' || mentionType == 'publishers') {
final data = mentionType == 'accounts'
? ref.watch(accountProvider(id))
: ref.watch(publisherProvider(id));
return data.when(
data: (profile) {
final picture = mentionType == 'accounts'
? (profile as SnAccount).profile.picture
: (profile as SnPublisher).picture;
final icon = mentionType == 'accounts'
? Symbols.person_rounded
: Symbols.design_services_rounded;
return _buildChip(
ProfilePictureWidget(file: picture, fallbackIcon: icon, radius: 9),
id,
isHovered,
);
},
error: (_, _) => Text(
alias,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
loading: () => Text(
alias,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
);
}
return _buildStaticChip(mentionType, id);
}
Widget _buildChip(
Widget avatar,
String displayName,
ValueNotifier<bool> isHovered,
) {
return InkWell(
onTap: onTap,
onHover: (value) => isHovered.value = value,
borderRadius: BorderRadius.circular(32),
child: Container(
padding: const EdgeInsets.only(
left: 5,
right: 7,
top: 2.5,
bottom: 2.5,
),
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(32),
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
Container(
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.5),
borderRadius: const BorderRadius.all(Radius.circular(32)),
),
child: avatar,
),
Text(
displayName,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Widget _buildStaticChip(String type, String id) {
final icon = switch (type) {
'chat' => Symbols.forum_rounded,
'realms' => Symbols.group_rounded,
_ => Symbols.person_rounded,
};
return _buildChip(
Icon(icon, size: 14, color: foregroundColor, fill: 1).padding(all: 2),
id,
useState(false),
);
}
}
class MentionChipSpanNode extends SpanNode {
final Map<String, String> attributes;
final Color backgroundColor;
@@ -393,98 +524,15 @@ class MentionChipSpanNode extends SpanNode {
final type = attributes['type'] ?? '';
final id = attributes['id'] ?? '';
final parts = alias.substring(1).split('/');
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: InkWell(
child: _MentionChipContent(
mentionType: type,
id: id,
alias: alias,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
onTap: () => onTap(type, id),
borderRadius: BorderRadius.circular(32),
child: Container(
padding: const EdgeInsets.only(
left: 5,
right: 7,
top: 2.5,
bottom: 2.5,
),
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(32),
),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
Container(
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.5),
borderRadius: const BorderRadius.all(Radius.circular(32)),
),
child: switch (parts.length == 1 ? 'u' : parts.first) {
'u' => Consumer(
builder: (context, ref, _) {
final userData = ref.watch(accountProvider(parts.last));
return userData.when(
data: (data) => ProfilePictureWidget(
file: data.profile.picture,
fallbackIcon: Symbols.person_rounded,
radius: 9,
),
error: (_, _) => const Icon(Symbols.close, size: 20),
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
padding: EdgeInsets.zero,
),
),
);
},
),
'p' => Consumer(
builder: (context, ref, _) {
final pubData = ref.watch(publisherProvider(parts.last));
return pubData.when(
data: (data) => ProfilePictureWidget(
file: data.picture,
fallbackIcon: Symbols.design_services_rounded,
radius: 9,
),
error: (_, _) => const Icon(Symbols.close, size: 20),
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
padding: EdgeInsets.zero,
),
),
);
},
),
_ => Icon(
(switch (parts.length == 1 ? 'u' : parts.first) {
'c' => Symbols.forum_rounded,
'r' => Symbols.group_rounded,
_ => Symbols.person_rounded,
}),
size: 14,
color: foregroundColor,
fill: 1,
).padding(all: 2),
},
),
Text(
parts.last,
style: TextStyle(
color: backgroundColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
);
}

View File

@@ -67,6 +67,7 @@ class PostActionableItem extends HookConsumerWidget {
final bool isEmbedReply;
final bool isEmbedOpenable;
final bool isCompact;
final bool hideAttachments;
final double? borderRadius;
final VoidCallback? onRefresh;
final Function(SnPost)? onUpdate;
@@ -80,6 +81,7 @@ class PostActionableItem extends HookConsumerWidget {
this.isEmbedReply = true,
this.isEmbedOpenable = false,
this.isCompact = false,
this.hideAttachments = false,
this.borderRadius,
this.onRefresh,
this.onUpdate,
@@ -110,6 +112,7 @@ class PostActionableItem extends HookConsumerWidget {
isEmbedOpenable: isEmbedOpenable,
isTextSelectable: false,
isCompact: isCompact,
hideAttachments: hideAttachments,
onRefresh: onRefresh,
onUpdate: onUpdate,
onOpen: onOpen,
@@ -308,6 +311,7 @@ class PostItem extends HookConsumerWidget {
final bool isTextSelectable;
final bool isTranslatable;
final bool isCompact;
final bool hideAttachments;
final double? textScale;
final VoidCallback? onRefresh;
final Function(SnPost)? onUpdate;
@@ -323,6 +327,7 @@ class PostItem extends HookConsumerWidget {
this.isTextSelectable = true,
this.isTranslatable = true,
this.isCompact = false,
this.hideAttachments = false,
this.textScale,
this.onRefresh,
this.onUpdate,
@@ -569,6 +574,7 @@ class PostItem extends HookConsumerWidget {
isTextSelectable: isTextSelectable,
translationSection: translationSection,
renderingPadding: renderingPadding,
hideAttachments: hideAttachments,
),
if (item.embedView != null)
EmbedViewRenderer(

View File

@@ -10,6 +10,7 @@ class PostItemSkeleton extends StatelessWidget {
final bool isEmbedReply;
final bool isCompact;
final double? borderRadius;
final double maxWidth;
const PostItemSkeleton({
super.key,
@@ -19,6 +20,7 @@ class PostItemSkeleton extends StatelessWidget {
this.isEmbedReply = false,
this.isCompact = false,
this.borderRadius,
this.maxWidth = 640,
});
@override
@@ -28,7 +30,7 @@ class PostItemSkeleton extends StatelessWidget {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
constraints: BoxConstraints(maxWidth: maxWidth),
child: Card(
margin: EdgeInsets.only(bottom: 8),
child: Column(

View File

@@ -69,7 +69,7 @@ class SliverPostList extends HookConsumerWidget {
isSliver: true,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: const PostItemSkeleton(),
child: PostItemSkeleton(maxWidth: maxWidth ?? double.infinity),
),
itemBuilder: (context, index, post) {
if (maxWidth != null) {

View File

@@ -50,7 +50,7 @@ class PostRepliesList extends HookConsumerWidget {
final skeletonItem = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: const PostItemSkeleton(),
child: PostItemSkeleton(maxWidth: maxWidth ?? double.infinity),
);
return PaginationList(

View File

@@ -870,6 +870,7 @@ class PostBody extends ConsumerWidget {
final EdgeInsets renderingPadding;
final bool isRelativeTime;
final bool hideOverlay;
final bool hideAttachments;
final double? textScale;
const PostBody({
@@ -882,6 +883,7 @@ class PostBody extends ConsumerWidget {
this.renderingPadding = EdgeInsets.zero,
this.isRelativeTime = true,
this.hideOverlay = false,
this.hideAttachments = false,
this.textScale,
});
@@ -1140,7 +1142,7 @@ class PostBody extends ConsumerWidget {
right: renderingPadding.horizontal,
),
),
if (item.attachments.isNotEmpty && item.type != 1)
if (item.attachments.isNotEmpty && item.type != 1 && !hideAttachments)
CloudFileList(
files: item.attachments,
isColumn: !isInteractive,

View File

@@ -774,6 +774,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_app_intents:
dependency: "direct main"
description:
name: flutter_app_intents
sha256: bec5a1ec991b93d475435205dbdca6efdd8979749f2a3c73ebb9f8334b9d3fa2
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_app_update:
dependency: "direct main"
description:
@@ -2221,6 +2229,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
quick_actions:
dependency: "direct main"
description:
name: quick_actions
sha256: "7e35dd6a21f5bbd21acf6899039eaf85001a5ac26d52cbd6a8a2814505b90798"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
quick_actions_android:
dependency: transitive
description:
name: quick_actions_android
sha256: "23f04632ada7fc16665d84ba54a0c792c09727e7fda6c989c6e6ba1853aa15dc"
url: "https://pub.dev"
source: hosted
version: "1.0.27"
quick_actions_ios:
dependency: transitive
description:
name: quick_actions_ios
sha256: a2e08ceb01f9d26e1b1826b1c4f5da6b7b6bbf61bcbaacd8e93dfff58b91f996
url: "https://pub.dev"
source: hosted
version: "1.2.3"
quick_actions_platform_interface:
dependency: transitive
description:
name: quick_actions_platform_interface
sha256: "1fec7068db5122cd019e9340d3d7be5d36eab099695ef3402c7059ee058329a4"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
recase:
dependency: transitive
description:

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 3.5.0+160
version: 3.5.0+161
environment:
sdk: ^3.8.0
@@ -38,6 +38,7 @@ dependencies:
cupertino_icons: ^1.0.8
flutter_hooks: ^0.21.3+1
hooks_riverpod: ^3.1.0
quick_actions: ^1.0.8
go_router: ^17.0.1
styled_widget: ^0.4.1
shared_preferences: ^2.5.4
@@ -173,6 +174,7 @@ dependencies:
shake: ^3.0.0
in_app_review: ^2.0.11
snow_fall_animation: ^0.0.1+3
flutter_app_intents: ^0.7.0
dev_dependencies:
flutter_test: