Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
5fc8859f3b
|
|||
|
e30e7adbe2
|
|||
|
68be4db160
|
|||
|
aa91e376ca
|
|||
|
caffb85588
|
|||
|
521b192205
|
|||
|
77ac0428ea
|
|||
|
88c8227c66
|
|||
|
b20d8350a8
|
|||
|
98b27bed0e
|
|||
|
3a7d8b1a0d
|
|||
|
b4801d6af6
|
|||
|
aab5b957af
|
|||
|
43d706a184
|
|||
|
98df275f88
|
|||
|
5663df6ef1
|
|||
|
e996a0c95f
|
|||
|
a090e93f57
|
|||
|
c69034c071
|
|||
|
369ea6cf5b
|
|||
|
2e371b5296
|
|||
|
2e9d61bcfa
|
|||
|
9c2b5b0dfa
|
|||
|
3b40f515b3
|
|||
|
5ee61dbef2
|
|||
|
b151ef6686
|
|||
|
ff934d0f08
|
@@ -233,6 +233,9 @@
|
||||
"pickFile": "Pick a file",
|
||||
"uploading": "Uploading",
|
||||
"uploadingProgress": "Uploading {} of {}",
|
||||
"upload": "Upload",
|
||||
"uploadSuccess": "Upload successful!",
|
||||
"wouldYouLikeToViewFile": "Would you like to view the file?",
|
||||
"uploadAll": "Upload All",
|
||||
"stickerCopyPlaceholder": "Copy Placeholder",
|
||||
"realmSelection": "Select a Realm",
|
||||
@@ -1110,7 +1113,6 @@
|
||||
"deleteRecycledFiles": "Delete Recycled Files",
|
||||
"recycledFilesDeleted": "Recycled files deleted successfully",
|
||||
"failedToDeleteRecycledFiles": "Failed to delete recycled files",
|
||||
"upload": "Upload",
|
||||
"updateAvailable": "Update available",
|
||||
"noChangelogProvided": "No changelog provided.",
|
||||
"useSecondarySourceForDownload": "Use secondary source for download",
|
||||
@@ -1471,5 +1473,6 @@
|
||||
"allFilesUploadedSuccess": "All files uploaded successfully",
|
||||
"lotteryLastNumberSpecial": "The last selected number will be your special number.",
|
||||
"lotteryMultiplierRequired": "Please enter a multiplier",
|
||||
"lotteryMultiplierRange": "Multiplier must be between 1 and 10"
|
||||
"lotteryMultiplierRange": "Multiplier must be between 1 and 10",
|
||||
"dropToShare": "Drop to share"
|
||||
}
|
||||
@@ -585,10 +585,10 @@
|
||||
"unknownChat": "未知聊天",
|
||||
"addAdditionalMessage": "添加附加消息……",
|
||||
"uploadingFiles": "上传文件中……",
|
||||
"sharedSuccessfully": "分享成功!",
|
||||
"shareSuccess": "分享成功!",
|
||||
"shareToSpecificChatSuccess": "成功分享至 {}!",
|
||||
"wouldYouLikeToGoToChat": "是否前往该聊天?",
|
||||
"sharedSuccessfully": "分享成功",
|
||||
"shareSuccess": "分享成功",
|
||||
"shareToSpecificChatSuccess": "成功分享至 {}",
|
||||
"wouldYouLikeToGoToChat": "是否前往该聊天页面?",
|
||||
"no": "否",
|
||||
"yes": "是",
|
||||
"navigateToChat": "前往聊天",
|
||||
|
||||
@@ -140,21 +140,29 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
guard !attachmentUrls.isEmpty else {
|
||||
print("Invalid URLs for attachments: \(attachmentUrls)")
|
||||
self.contentHandler?(content)
|
||||
return
|
||||
}
|
||||
|
||||
let targetSize = 512
|
||||
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var attachments: [UNNotificationAttachment] = []
|
||||
let lock = NSLock() // To synchronize access to the attachments array
|
||||
|
||||
for attachmentUrl in attachmentUrls {
|
||||
guard let remoteUrl = URL(string: attachmentUrl) else {
|
||||
print("Invalid URL for attachment: \(attachmentUrl)")
|
||||
continue // Skip this URL and move to the next one
|
||||
continue
|
||||
}
|
||||
|
||||
dispatchGroup.enter()
|
||||
|
||||
KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
|
||||
.processor(scaleProcessor)
|
||||
] : nil) { [weak self] result in
|
||||
defer { dispatchGroup.leave() }
|
||||
guard let self = self else { return }
|
||||
|
||||
switch result {
|
||||
@@ -166,49 +174,34 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
do {
|
||||
// Write the image data to a temporary file for UNNotificationAttachment
|
||||
try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
|
||||
self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: attachmentUrl)
|
||||
|
||||
if let attachment = try? UNNotificationAttachment(identifier: attachmentUrl, url: cachedFileUrl, options: [
|
||||
UNNotificationAttachmentOptionsTypeHintKey: type?.identifier as Any,
|
||||
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
|
||||
]) {
|
||||
lock.lock()
|
||||
attachments.append(attachment)
|
||||
lock.unlock()
|
||||
}
|
||||
} catch {
|
||||
print("Failed to write media to temporary file: \(error.localizedDescription)")
|
||||
self.contentHandler?(content)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
print("Failed to retrieve image: \(error.localizedDescription)")
|
||||
self.contentHandler?(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func attachLocalMedia(to content: UNMutableNotificationContent, fileType type: String?, from localUrl: URL, withIdentifier identifier: String) {
|
||||
do {
|
||||
let attachment = try UNNotificationAttachment(identifier: identifier, url: localUrl, options: [
|
||||
UNNotificationAttachmentOptionsTypeHintKey: type as Any,
|
||||
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
|
||||
])
|
||||
content.attachments = [attachment]
|
||||
} catch let error as NSError {
|
||||
// Log detailed error information
|
||||
print("Failed to create attachment from file at \(localUrl.path)")
|
||||
print("Error: \(error.localizedDescription)")
|
||||
|
||||
// Check specific error codes if needed
|
||||
if error.domain == NSCocoaErrorDomain {
|
||||
switch error.code {
|
||||
case NSFileReadNoSuchFileError:
|
||||
print("File does not exist at \(localUrl.path)")
|
||||
case NSFileReadNoPermissionError:
|
||||
print("No permission to read file at \(localUrl.path)")
|
||||
default:
|
||||
print("Unhandled file error: \(error.code)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call content handler regardless of success or failure
|
||||
self.contentHandler?(content)
|
||||
dispatchGroup.notify(queue: .main) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
content.attachments = attachments
|
||||
self.contentHandler?(content)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private func createMessageIntent(with sender: INPerson, meta: [AnyHashable: Any], body: String) -> INSendMessageIntent {
|
||||
INSendMessageIntent(
|
||||
recipients: nil,
|
||||
|
||||
@@ -449,4 +449,13 @@ class AppDatabase extends _$AppDatabase {
|
||||
chatMembers,
|
||||
).insert(companionFromMember(member), mode: InsertMode.insertOrReplace);
|
||||
}
|
||||
|
||||
Future<int> saveMessageWithSender(LocalChatMessage message) async {
|
||||
// First save the sender if it exists
|
||||
if (message.sender != null) {
|
||||
await saveMember(message.sender!);
|
||||
}
|
||||
// Then save the message
|
||||
return await saveMessage(messageToCompanion(message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'call.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$callNotifierHash() => r'2caee30f42315e539cb4df17c0d464ceed41ffa0';
|
||||
String _$callNotifierHash() => r'ef4e3e9c9d411cf9dce1ceb456a3b866b2c87db3';
|
||||
|
||||
/// See also [CallNotifier].
|
||||
@ProviderFor(CallNotifier)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
@@ -6,6 +8,58 @@ import 'package:island/pods/chat/chat_subscribe.dart';
|
||||
|
||||
part 'chat_summary.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatUnreadCountNotifier extends _$ChatUnreadCountNotifier {
|
||||
StreamSubscription<WebSocketPacket>? _subscription;
|
||||
|
||||
@override
|
||||
Future<int> build() async {
|
||||
// Subscribe to websocket events when this provider is built
|
||||
_subscribeToWebSocket();
|
||||
|
||||
// Dispose the subscription when this provider is disposed
|
||||
ref.onDispose(() {
|
||||
_subscription?.cancel();
|
||||
});
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/sphere/chat/unread');
|
||||
return (response.data as num).toInt();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToWebSocket() {
|
||||
final webSocketService = ref.read(websocketProvider);
|
||||
_subscription = webSocketService.dataStream.listen((packet) {
|
||||
if (packet.type == 'messages.new' && packet.data != null) {
|
||||
final message = SnChatMessage.fromJson(packet.data!);
|
||||
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
|
||||
// Only increment if the message is not from the currently subscribed chat
|
||||
if (message.chatRoomId != currentSubscribed) {
|
||||
_incrementCounter();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _incrementCounter() async {
|
||||
final current = await future;
|
||||
state = AsyncData(current + 1);
|
||||
}
|
||||
|
||||
Future<void> decrement(int count) async {
|
||||
final current = await future;
|
||||
state = AsyncData(math.max(current - count, 0));
|
||||
}
|
||||
|
||||
void clear() async {
|
||||
state = AsyncData(0);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ChatSummary extends _$ChatSummary {
|
||||
@override
|
||||
@@ -41,6 +95,14 @@ class ChatSummary extends _$ChatSummary {
|
||||
state.whenData((summaries) {
|
||||
final summary = summaries[chatId];
|
||||
if (summary != null) {
|
||||
// Decrement global unread count
|
||||
final unreadToDecrement = summary.unreadCount;
|
||||
if (unreadToDecrement > 0) {
|
||||
ref
|
||||
.read(chatUnreadCountNotifierProvider.notifier)
|
||||
.decrement(unreadToDecrement);
|
||||
}
|
||||
|
||||
state = AsyncData({
|
||||
...summaries,
|
||||
chatId: SnChatSummary(
|
||||
|
||||
@@ -6,6 +6,24 @@ part of 'chat_summary.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatUnreadCountNotifierHash() =>
|
||||
r'b8d93589dc37f772d4c3a07d9afd81c37026e57d';
|
||||
|
||||
/// See also [ChatUnreadCountNotifier].
|
||||
@ProviderFor(ChatUnreadCountNotifier)
|
||||
final chatUnreadCountNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<ChatUnreadCountNotifier, int>.internal(
|
||||
ChatUnreadCountNotifier.new,
|
||||
name: r'chatUnreadCountNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatUnreadCountNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ChatUnreadCountNotifier = AutoDisposeAsyncNotifier<int>;
|
||||
String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
|
||||
|
||||
/// See also [ChatSummary].
|
||||
|
||||
@@ -45,6 +45,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
bool _isSyncing = false;
|
||||
bool _isJumping = false;
|
||||
bool _isUpdatingState = false;
|
||||
bool _allRemoteMessagesFetched = false;
|
||||
DateTime? _lastPauseTime;
|
||||
|
||||
late final Future<SnAccount?> Function(String) _fetchAccount;
|
||||
@@ -278,6 +279,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
if (offset >= _totalCount!) {
|
||||
_allRemoteMessagesFetched = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -299,10 +301,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}).toList();
|
||||
|
||||
for (final message in messages) {
|
||||
await _database.saveMessage(_database.messageToCompanion(message));
|
||||
if (message.sender != null) {
|
||||
await _database.saveMember(message.sender!); // Save/update member data
|
||||
}
|
||||
await _database.saveMessageWithSender(message);
|
||||
if (message.nonce != null) {
|
||||
_pendingMessages.removeWhere(
|
||||
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
|
||||
@@ -310,6 +309,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've fetched all remote messages
|
||||
if (offset + messages.length >= _totalCount!) {
|
||||
_allRemoteMessagesFetched = true;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -319,6 +323,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return;
|
||||
}
|
||||
_isSyncing = true;
|
||||
_allRemoteMessagesFetched = false;
|
||||
|
||||
talker.log('Starting message sync');
|
||||
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
|
||||
@@ -346,19 +351,48 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final resp = await _apiClient.post(
|
||||
'/sphere/chat/${_room.id}/sync',
|
||||
data: {
|
||||
'last_sync_timestamp':
|
||||
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
|
||||
},
|
||||
);
|
||||
// Sync with pagination support using timestamp-based cursor
|
||||
int? totalMessages;
|
||||
int syncedCount = 0;
|
||||
int lastSyncTimestamp =
|
||||
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch;
|
||||
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
talker.log('Sync response: ${response.messages.length} changes');
|
||||
for (final message in response.messages) {
|
||||
await receiveMessage(message);
|
||||
}
|
||||
do {
|
||||
final resp = await _apiClient.post(
|
||||
'/sphere/chat/${_room.id}/sync',
|
||||
data: {'last_sync_timestamp': lastSyncTimestamp},
|
||||
);
|
||||
|
||||
// Read total count from header on first request
|
||||
if (totalMessages == null) {
|
||||
totalMessages = int.parse(
|
||||
resp.headers['x-total']?.firstOrNull ?? '0',
|
||||
);
|
||||
talker.log('Total messages to sync: $totalMessages');
|
||||
}
|
||||
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
final messagesCount = response.messages.length;
|
||||
talker.log(
|
||||
'Sync page: synced=$syncedCount/$totalMessages, count=$messagesCount',
|
||||
);
|
||||
|
||||
for (final message in response.messages) {
|
||||
await receiveMessage(message);
|
||||
}
|
||||
|
||||
syncedCount += messagesCount;
|
||||
|
||||
// Update cursor to the last message's createdAt for next page
|
||||
if (response.messages.isNotEmpty) {
|
||||
lastSyncTimestamp =
|
||||
response.messages.last.createdAt.millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
// Continue if there are more messages to fetch
|
||||
} while (syncedCount < totalMessages);
|
||||
|
||||
talker.log('Sync complete: synced $syncedCount messages');
|
||||
} catch (err, stackTrace) {
|
||||
talker.log(
|
||||
'Error syncing messages',
|
||||
@@ -397,14 +431,35 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
|
||||
if (localMessages.isNotEmpty) {
|
||||
// If we have local messages AND we've fetched all remote messages, return local
|
||||
if (localMessages.isNotEmpty && _allRemoteMessagesFetched) {
|
||||
return localMessages;
|
||||
}
|
||||
|
||||
// If we haven't fetched all remote messages, check remote even if we have local
|
||||
// OR if we have no local messages at all
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
return await _fetchAndCacheMessages(offset: offset, take: take);
|
||||
final remoteMessages = await _fetchAndCacheMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
);
|
||||
|
||||
// If we got remote messages, re-fetch from cache to get merged result
|
||||
if (remoteMessages.isNotEmpty) {
|
||||
return await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
searchQuery: _searchQuery,
|
||||
withLinks: _withLinks,
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
}
|
||||
|
||||
// No remote messages, return local (if any)
|
||||
return localMessages;
|
||||
} else {
|
||||
return []; // If searching, and no local messages, don't fetch from network
|
||||
// For search queries, return local only
|
||||
return localMessages;
|
||||
}
|
||||
} catch (e) {
|
||||
final localMessages = await _getCachedMessages(
|
||||
@@ -424,6 +479,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
talker.log('Loading initial messages');
|
||||
_allRemoteMessagesFetched = false;
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
syncMessages();
|
||||
}
|
||||
@@ -445,6 +501,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
if (!_hasMore || state is AsyncLoading) return;
|
||||
talker.log('Loading more messages');
|
||||
|
||||
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
|
||||
try {
|
||||
final currentMessages = state.value ?? [];
|
||||
final offset = currentMessages.length;
|
||||
@@ -466,6 +523,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
Future.microtask(
|
||||
() => ref.read(isSyncingProvider.notifier).state = false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,8 +562,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages[localMessage.id] = localMessage;
|
||||
_fileUploadProgress[localMessage.id] = {};
|
||||
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||
await _database.saveMember(mockMessage.sender);
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||
@@ -553,7 +613,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages.remove(localMessage.id);
|
||||
await _database.deleteMessage(localMessage.id);
|
||||
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
@@ -635,7 +695,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages.remove(pendingMessageId);
|
||||
await _database.deleteMessage(pendingMessageId);
|
||||
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final newMessages =
|
||||
(state.value ?? []).map((m) {
|
||||
@@ -692,7 +752,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
@@ -789,7 +849,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
messageToUpdate.status,
|
||||
);
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(deletedMessage));
|
||||
await _database.saveMessageWithSender(deletedMessage);
|
||||
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
@@ -913,6 +973,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_searchQuery = null;
|
||||
_withLinks = null;
|
||||
_withAttachments = null;
|
||||
_allRemoteMessagesFetched = false;
|
||||
loadInitial();
|
||||
}
|
||||
|
||||
@@ -938,7 +999,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
MessageStatus.sent,
|
||||
);
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(message));
|
||||
await _database.saveMessageWithSender(message);
|
||||
return message;
|
||||
} catch (e) {
|
||||
if (e is DioException) return null;
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$messagesNotifierHash() => r'27e5d686d9204ba39adbd1838cf4a6eaea0ac85f';
|
||||
String _$messagesNotifierHash() => r'27ce32c54e317a04e1d554ed4a70a24e4503fdd1';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -62,6 +62,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountDeletionHint'.tr(),
|
||||
'accountDeletion'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,7 @@ class AuthFactorSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authFactorDeleteHint'.tr(),
|
||||
'authFactorDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
|
||||
@@ -82,6 +82,7 @@ class AccountConnectionSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountConnectionDeleteHint'.tr(),
|
||||
'accountConnectionDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
@@ -332,6 +333,7 @@ class AccountConnectionsSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountConnectionDeleteHint'.tr(),
|
||||
'accountConnectionDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (confirm && context.mounted) {
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ class ContactMethodSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'contactMethodDeleteHint'.tr(),
|
||||
'contactMethodDelete'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
|
||||
@@ -6,11 +6,12 @@ import 'package:island/widgets/content/sheet.dart';
|
||||
|
||||
class CaptchaScreen extends ConsumerWidget {
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
return Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// ignore_for_file: invalid_runtime_check_with_js_interop_types
|
||||
|
||||
import 'dart:ui_web' as ui;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
@@ -10,11 +8,12 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class CaptchaScreen extends ConsumerStatefulWidget {
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isDismissible: false,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
return Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +28,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
|
||||
|
||||
void _setupWebListener(String serverUrl) async {
|
||||
web.window.onMessage.listen((event) {
|
||||
// ignore: invalid_runtime_check_with_js_interop_types
|
||||
if (event.data != null && event.data is String) {
|
||||
// ignore: invalid_runtime_check_with_js_interop_types
|
||||
final message = event.data as String;
|
||||
if (message.startsWith("captcha_tk=")) {
|
||||
String token = message.replaceFirst("captcha_tk=", "");
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:island/models/chat.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/database.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/pods/chat/chat_summary.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
@@ -28,6 +27,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
part 'chat.g.dart';
|
||||
|
||||
@@ -289,7 +289,6 @@ class ChatListBodyWidget extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chats = ref.watch(chatroomsJoinedProvider);
|
||||
final callState = ref.watch(callNotifierProvider);
|
||||
|
||||
Widget bodyWidget = Column(
|
||||
children: [
|
||||
@@ -314,10 +313,8 @@ class ChatListBodyWidget extends HookConsumerWidget {
|
||||
() => Future.sync(() {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
}),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: callState.isConnected ? 96 : 0,
|
||||
),
|
||||
child: SuperListView.builder(
|
||||
padding: EdgeInsets.only(bottom: 96),
|
||||
itemCount:
|
||||
items
|
||||
.where(
|
||||
|
||||
@@ -487,6 +487,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteChatRoomHint'.tr(),
|
||||
'deleteChatRoom'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) async {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
@@ -304,16 +304,18 @@ class CreatorHubScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void deletePublisher() {
|
||||
showConfirmAlert('deletePublisherHint'.tr(), 'deletePublisher'.tr()).then(
|
||||
(confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client.delete('/sphere/publishers/${currentPublisher.value!.name}');
|
||||
ref.invalidate(publishersManagedProvider);
|
||||
currentPublisher.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
showConfirmAlert(
|
||||
'deletePublisherHint'.tr(),
|
||||
'deletePublisher'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client.delete('/sphere/publishers/${currentPublisher.value!.name}');
|
||||
ref.invalidate(publishersManagedProvider);
|
||||
currentPublisher.value = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final List<DropdownMenuItem<SnPublisher>> publishersMenu = publishers.when(
|
||||
|
||||
@@ -8,18 +8,16 @@ import 'package:island/pods/site_pages.dart';
|
||||
import 'package:island/widgets/sites/page_form.dart';
|
||||
import 'package:island/widgets/sites/site_action_menu.dart';
|
||||
import 'package:island/widgets/sites/site_detail_content.dart';
|
||||
import 'package:island/widgets/sites/site_info_card.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/widgets/sites/info_row.dart';
|
||||
import 'package:island/widgets/sites/pages_section.dart';
|
||||
import 'package:island/widgets/sites/file_management_section.dart';
|
||||
import 'package:island/widgets/sites/file_management_action_section.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
part 'site_detail.g.dart';
|
||||
|
||||
@@ -67,7 +65,6 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
|
||||
),
|
||||
body: siteAsync.when(
|
||||
data: (site) {
|
||||
final theme = Theme.of(context);
|
||||
if (isWideScreen(context)) {
|
||||
return ExtendedRefreshIndicator(
|
||||
onRefresh:
|
||||
@@ -99,76 +96,7 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'siteInformation'.tr(),
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Gap(16),
|
||||
InfoRow(
|
||||
label: 'name'.tr(),
|
||||
value: site.name,
|
||||
icon: Symbols.title,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'slug'.tr(),
|
||||
value: site.slug,
|
||||
icon: Symbols.tag,
|
||||
monospace: true,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteDomain'.tr(),
|
||||
value: '${site.slug}.solian.page',
|
||||
icon: Symbols.globe,
|
||||
monospace: true,
|
||||
onTap: () {
|
||||
final url =
|
||||
'https://${site.slug}.solian.page';
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteMode'.tr(),
|
||||
value:
|
||||
site.mode == 0
|
||||
? 'siteModeFullyManaged'.tr()
|
||||
: 'siteModeSelfManaged'.tr(),
|
||||
icon: Symbols.settings,
|
||||
),
|
||||
if (site.description != null &&
|
||||
site.description!.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'description'.tr(),
|
||||
value: site.description!,
|
||||
icon: Symbols.description,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteCreated'.tr(),
|
||||
value: site.createdAt.formatSystem(),
|
||||
icon: Symbols.calendar_add_on,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteUpdated'.tr(),
|
||||
value: site.updatedAt.formatSystem(),
|
||||
icon: Symbols.update,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SiteInfoCard(site: site),
|
||||
const Gap(8),
|
||||
if (site.mode == 1) // Self-Managed only
|
||||
FileManagementActionSection(
|
||||
|
||||
@@ -190,6 +190,7 @@ class SiteForm extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'publicationSiteDeleteConfirm'.tr(),
|
||||
'deletePublicationSite'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
|
||||
@@ -221,7 +221,9 @@ class _CreatorSiteItem extends HookConsumerWidget {
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/zone/sites/${site.id}');
|
||||
await client.delete(
|
||||
'/zone/sites/$pubName/${site.slug}',
|
||||
);
|
||||
ref.invalidate(siteListNotifierProvider(pubName));
|
||||
showSnackBar('siteDeletedSuccess'.tr());
|
||||
} catch (e) {
|
||||
|
||||
@@ -288,6 +288,7 @@ class StickerPackActionMenu extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteStickerPackHint'.tr(),
|
||||
'deleteStickerPack'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
@@ -70,6 +70,7 @@ class WebfeedForm extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'Are you sure you want to delete this web feed? This action cannot be undone.',
|
||||
'Delete Web Feed',
|
||||
isDanger: true,
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ class AppSecretsScreen extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteSecretHint'.tr(),
|
||||
'deleteSecret'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
@@ -231,6 +231,7 @@ class CustomAppsScreen extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteCustomAppHint'.tr(),
|
||||
'deleteCustomApp'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(
|
||||
|
||||
@@ -159,9 +159,11 @@ class BotKeysScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void revokeKey(String keyId) {
|
||||
showConfirmAlert('revokeBotKeyHint'.tr(), 'revokeBotKey'.tr()).then((
|
||||
confirm,
|
||||
) {
|
||||
showConfirmAlert(
|
||||
'revokeBotKeyHint'.tr(),
|
||||
'revokeBotKey'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
client
|
||||
|
||||
@@ -172,6 +172,7 @@ class BotsScreen extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteBotHint'.tr(),
|
||||
'deleteBot'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
@@ -631,6 +631,7 @@ class _ProjectListTile extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteProjectHint'.tr(),
|
||||
'deleteProject'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.read(apiClientProvider);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -30,7 +31,9 @@ import 'package:island/widgets/publisher/publisher_card.dart';
|
||||
import 'package:island/widgets/web_article_card.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/widgets/share/share_sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
part 'explore.g.dart';
|
||||
|
||||
@@ -239,23 +242,74 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
|
||||
final appBar = isWide ? null : _buildAppBar(tabController, context);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: appBar,
|
||||
body:
|
||||
isWide
|
||||
? _buildWideBody(
|
||||
context,
|
||||
ref,
|
||||
filterBar,
|
||||
user,
|
||||
notificationCount,
|
||||
query,
|
||||
events,
|
||||
selectedDay,
|
||||
currentFilter.value,
|
||||
)
|
||||
: _buildNarrowBody(context, ref, currentFilter.value),
|
||||
final dragging = useState(false);
|
||||
|
||||
return DropTarget(
|
||||
onDragDone: (detail) {
|
||||
dragging.value = false;
|
||||
if (detail.files.isNotEmpty) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => ShareSheet.files(files: detail.files),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDragEntered: (_) => dragging.value = true,
|
||||
onDragExited: (_) => dragging.value = false,
|
||||
child: Stack(
|
||||
children: [
|
||||
AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: appBar,
|
||||
body:
|
||||
isWide
|
||||
? _buildWideBody(
|
||||
context,
|
||||
ref,
|
||||
filterBar,
|
||||
user,
|
||||
notificationCount,
|
||||
query,
|
||||
events,
|
||||
selectedDay,
|
||||
currentFilter.value,
|
||||
)
|
||||
: _buildNarrowBody(context, ref, currentFilter.value),
|
||||
),
|
||||
if (dragging.value)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withOpacity(0.9),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.upload_file,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'dropToShare'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -582,7 +636,7 @@ class _DiscoveryActivityItem extends StatelessWidget {
|
||||
final height = type == 'post' ? 280.0 : 180.0;
|
||||
|
||||
final contentWidget = switch (type) {
|
||||
'post' => ListView.separated(
|
||||
'post' => SuperListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (context, index) => const Gap(12),
|
||||
|
||||
@@ -36,7 +36,7 @@ class FileListScreen extends HookConsumerWidget {
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text('files').tr(),
|
||||
leading: const PageBackButton(),
|
||||
leading: const PageBackButton(backTo: '/account'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.bar_chart),
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
@@ -68,6 +69,16 @@ class NotificationUnreadCountNotifier
|
||||
void clear() async {
|
||||
state = AsyncData(0);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/ring/notifications/count');
|
||||
state = AsyncData((response.data as num).toInt());
|
||||
} catch (_) {
|
||||
// Keep the current state if refresh fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
@@ -115,8 +126,36 @@ class NotificationListNotifier extends _$NotificationListNotifier
|
||||
class NotificationSheet extends HookConsumerWidget {
|
||||
const NotificationSheet({super.key});
|
||||
|
||||
IconData _getNotificationIcon(String topic) {
|
||||
switch (topic) {
|
||||
case 'post.replies':
|
||||
return Symbols.reply;
|
||||
case 'wallet.transactions':
|
||||
return Symbols.account_balance_wallet;
|
||||
case 'relationships.friends.request':
|
||||
return Symbols.person_add;
|
||||
case 'invites.chat':
|
||||
return Symbols.chat;
|
||||
case 'invites.realm':
|
||||
return Symbols.domain;
|
||||
case 'auth.login':
|
||||
return Symbols.login;
|
||||
case 'posts.new':
|
||||
return Symbols.post_add;
|
||||
case 'wallet.orders.paid':
|
||||
return Symbols.shopping_bag;
|
||||
case 'posts.reactions.new':
|
||||
return Symbols.add_reaction;
|
||||
default:
|
||||
return Symbols.notifications;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Refresh unread count when sheet opens to sync across devices
|
||||
ref.read(notificationUnreadCountNotifierProvider.notifier).refresh();
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
@@ -149,12 +188,30 @@ class NotificationSheet extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final notification = data.items[index];
|
||||
final pfp = notification.meta['pfp'] as String?;
|
||||
final images = notification.meta['images'] as List?;
|
||||
final imageIds = images?.cast<String>() ?? [];
|
||||
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
leading:
|
||||
pfp != null
|
||||
? ProfilePictureWidget(fileId: pfp, radius: 20)
|
||||
: CircleAvatar(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
_getNotificationIcon(notification.topic),
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
title: Text(notification.title),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -187,6 +244,29 @@ class NotificationSheet extends HookConsumerWidget {
|
||||
).colorScheme.onSurface.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
if (imageIds.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children:
|
||||
imageIds.map((imageId) {
|
||||
return SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CloudImageWidget(
|
||||
fileId: imageId,
|
||||
aspectRatio: 1,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing:
|
||||
|
||||
@@ -256,21 +256,25 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ComposeFormFields(
|
||||
state: state,
|
||||
showPublisherAvatar: false,
|
||||
onPublisherTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
).padding(top: 16),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: ComposeFormFields(
|
||||
state: state,
|
||||
showPublisherAvatar: false,
|
||||
onPublisherTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
).padding(top: 16),
|
||||
),
|
||||
),
|
||||
|
||||
// Attachments preview
|
||||
ValueListenableBuilder<List<UniversalFile>>(
|
||||
|
||||
@@ -145,9 +145,11 @@ class PostActionButtons extends HookConsumerWidget {
|
||||
message: 'delete'.tr(),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () {
|
||||
showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then((
|
||||
confirm,
|
||||
) {
|
||||
showConfirmAlert(
|
||||
'deletePostHint'.tr(),
|
||||
'deletePost'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
|
||||
@@ -427,6 +427,7 @@ class _RealmActionMenu extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deleteRealmHint'.tr(),
|
||||
'deleteRealm'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:island/widgets/navigation/fab_menu.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/chat/chat_summary.dart';
|
||||
|
||||
final currentRouteProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
@@ -50,6 +51,8 @@ class TabsScreen extends HookConsumerWidget {
|
||||
notificationUnreadCountNotifierProvider,
|
||||
);
|
||||
|
||||
final chatUnreadCount = ref.watch(chatUnreadCountNotifierProvider);
|
||||
|
||||
final wideScreen = isWideScreen(context);
|
||||
|
||||
final destinations = [
|
||||
@@ -59,7 +62,11 @@ class TabsScreen extends HookConsumerWidget {
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'chat'.tr(),
|
||||
icon: const Icon(Symbols.forum_rounded),
|
||||
icon: Badge.count(
|
||||
count: chatUnreadCount.value ?? 0,
|
||||
isLabelVisible: (chatUnreadCount.value ?? 0) > 0,
|
||||
child: const Icon(Symbols.forum_rounded),
|
||||
),
|
||||
),
|
||||
NavigationDestination(
|
||||
label: 'realms'.tr(),
|
||||
|
||||
@@ -150,6 +150,7 @@ class AccountSessionSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authDeviceLogoutHint'.tr(),
|
||||
'authDeviceLogout'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
@@ -276,6 +277,7 @@ class AccountSessionSheet extends HookConsumerWidget {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authDeviceLogoutHint'.tr(),
|
||||
'authDeviceLogout'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (confirm && context.mounted) {
|
||||
try {
|
||||
|
||||
@@ -25,12 +25,24 @@ const Map<String, Color> kUsernamePlainColors = {
|
||||
'white': Colors.white,
|
||||
};
|
||||
|
||||
const kVerificationMarkColors = [
|
||||
const List<IconData> kVerificationMarkIcons = [
|
||||
Symbols.build_circle,
|
||||
Symbols.verified,
|
||||
Symbols.verified,
|
||||
Symbols.account_balance,
|
||||
Symbols.palette,
|
||||
Symbols.code,
|
||||
Symbols.masks,
|
||||
];
|
||||
|
||||
const List<Color> kVerificationMarkColors = [
|
||||
Colors.teal,
|
||||
Colors.blue,
|
||||
Colors.amber,
|
||||
Colors.blueGrey,
|
||||
Colors.lightBlue,
|
||||
Colors.indigo,
|
||||
Colors.red,
|
||||
Colors.orange,
|
||||
Colors.blue,
|
||||
Colors.blueAccent,
|
||||
];
|
||||
|
||||
class AccountName extends StatelessWidget {
|
||||
@@ -291,13 +303,14 @@ class VerificationMark extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = Icon(
|
||||
mark.type == 4
|
||||
? Symbols.play_circle
|
||||
: mark.type == 0
|
||||
? Symbols.build_circle
|
||||
(kVerificationMarkIcons.length > mark.type && mark.type >= 0)
|
||||
? kVerificationMarkIcons[mark.type]
|
||||
: Symbols.verified,
|
||||
size: 16,
|
||||
color: kVerificationMarkColors[mark.type],
|
||||
color:
|
||||
(kVerificationMarkColors.length > mark.type && mark.type >= 0)
|
||||
? kVerificationMarkColors[mark.type]
|
||||
: Colors.blue,
|
||||
fill: 1,
|
||||
);
|
||||
|
||||
@@ -394,13 +407,14 @@ class VerificationStatusCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
mark.type == 4
|
||||
? Symbols.play_circle
|
||||
: mark.type == 0
|
||||
? Symbols.build_circle
|
||||
(kVerificationMarkIcons.length > mark.type && mark.type >= 0)
|
||||
? kVerificationMarkIcons[mark.type]
|
||||
: Symbols.verified,
|
||||
size: 32,
|
||||
color: kVerificationMarkColors[mark.type],
|
||||
color:
|
||||
(kVerificationMarkColors.length > mark.type && mark.type >= 0)
|
||||
? kVerificationMarkColors[mark.type]
|
||||
: Colors.blue,
|
||||
fill: 1,
|
||||
).alignment(Alignment.centerLeft),
|
||||
const Gap(8),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/main.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:top_snackbar_flutter/top_snack_bar.dart';
|
||||
|
||||
@@ -156,6 +157,9 @@ String _parseRemoteError(DioException err) {
|
||||
return message ?? err.toString();
|
||||
}
|
||||
|
||||
// Track active overlay dialogs for dismissal
|
||||
final List<void Function()> _activeOverlayDialogs = [];
|
||||
|
||||
Future<T?> showOverlayDialog<T>({
|
||||
required Widget Function(BuildContext context, void Function(T? result) close)
|
||||
builder,
|
||||
@@ -174,6 +178,7 @@ Future<T?> showOverlayDialog<T>({
|
||||
}
|
||||
|
||||
entry.remove();
|
||||
_activeOverlayDialogs.remove(close);
|
||||
completer.complete(result);
|
||||
}
|
||||
|
||||
@@ -214,11 +219,24 @@ Future<T?> showOverlayDialog<T>({
|
||||
),
|
||||
);
|
||||
|
||||
_activeOverlayDialogs.add(() => close(null));
|
||||
globalOverlay.currentState!.insert(entry);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void showErrorAlert(dynamic err) {
|
||||
// Close the topmost overlay dialog if any exists
|
||||
bool closeTopmostOverlayDialog() {
|
||||
if (_activeOverlayDialogs.isNotEmpty) {
|
||||
final closeFunc = _activeOverlayDialogs.last;
|
||||
closeFunc();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const kDialogMaxWidth = 480.0;
|
||||
|
||||
void showErrorAlert(dynamic err, {IconData? icon}) {
|
||||
if (err is Error) {
|
||||
talker.error('Something went wrong...', err, err.stackTrace);
|
||||
}
|
||||
@@ -231,51 +249,128 @@ void showErrorAlert(dynamic err) {
|
||||
|
||||
showOverlayDialog<void>(
|
||||
builder:
|
||||
(context, close) => AlertDialog(
|
||||
title: Text('somethingWentWrong'.tr()),
|
||||
content: Text(text),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(null),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
(context, close) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
|
||||
child: AlertDialog(
|
||||
title: null,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline_rounded,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'somethingWentWrong'.tr(),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(text),
|
||||
],
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(null),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showInfoAlert(String message, String title) {
|
||||
void showInfoAlert(String message, String title, {IconData? icon}) {
|
||||
showOverlayDialog<void>(
|
||||
builder:
|
||||
(context, close) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(null),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
(context, close) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
|
||||
child: AlertDialog(
|
||||
title: null,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Symbols.info_rounded,
|
||||
fill: 1,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||
const Gap(8),
|
||||
Text(message),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(null),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> showConfirmAlert(String message, String title) async {
|
||||
Future<bool> showConfirmAlert(
|
||||
String message,
|
||||
String title, {
|
||||
IconData? icon,
|
||||
bool isDanger = false,
|
||||
}) async {
|
||||
final result = await showOverlayDialog<bool>(
|
||||
builder:
|
||||
(context, close) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
(context, close) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
|
||||
child: AlertDialog(
|
||||
title: null,
|
||||
titlePadding: EdgeInsets.zero,
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Symbols.help_rounded,
|
||||
size: 48,
|
||||
fill: 1,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(16),
|
||||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||
const Gap(8),
|
||||
Text(message),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => close(true),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(false),
|
||||
child: Text(
|
||||
MaterialLocalizations.of(context).cancelButtonLabel,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => close(true),
|
||||
style:
|
||||
isDanger
|
||||
? TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
)
|
||||
: null,
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:island/route.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/upload_overlay.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -364,6 +365,12 @@ class PopAction extends Action<PopIntent> {
|
||||
|
||||
@override
|
||||
void invoke(PopIntent intent) {
|
||||
// First, try to close any overlay dialogs
|
||||
if (closeTopmostOverlayDialog()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no overlay to close, pop the route
|
||||
if (ref.watch(routerProvider).canPop()) {
|
||||
ref.read(routerProvider).pop();
|
||||
}
|
||||
|
||||
@@ -596,6 +596,7 @@ class MessageHoverActionMenu extends StatelessWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'deleteMessageConfirmation'.tr(),
|
||||
'deleteMessage'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
|
||||
if (confirmed) {
|
||||
|
||||
@@ -77,27 +77,12 @@ class MessageSenderInfo extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AccountName(
|
||||
account: sender.account,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Badge(
|
||||
label:
|
||||
Text(
|
||||
sender.role >= 100
|
||||
? 'permissionOwner'
|
||||
: sender.role >= 50
|
||||
? 'permissionModerator'
|
||||
: 'permissionMember',
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
AccountName(
|
||||
account: sender.account,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
timestamp,
|
||||
|
||||
@@ -55,14 +55,18 @@ class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
|
||||
stream.removeListener(listener);
|
||||
|
||||
final aspectRatio = info.image.width / info.image.height;
|
||||
setState(() {
|
||||
_isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// If error, assume not square
|
||||
setState(() {
|
||||
_isSquare = false;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSquare = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ class NetworkStatusSheet extends HookConsumerWidget {
|
||||
final wsState = ref.watch(websocketStateProvider);
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: 0.4,
|
||||
titleText:
|
||||
wsState == WebSocketState.connected()
|
||||
? 'Connection Status'
|
||||
|
||||
@@ -512,6 +512,7 @@ class FileListView extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'Are you sure you want to delete the selected files?',
|
||||
'Delete Selected Files',
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
if (context.mounted) {
|
||||
@@ -742,22 +743,25 @@ class FileListView extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: onPickAndUpload,
|
||||
icon: const Icon(Symbols.upload_file),
|
||||
label: const Text('Upload Files'),
|
||||
),
|
||||
const Gap(12),
|
||||
OutlinedButton.icon(
|
||||
onPressed:
|
||||
() => onShowCreateDirectory(ref.context, currentPath),
|
||||
icon: const Icon(Symbols.create_new_folder),
|
||||
label: const Text('Create Directory'),
|
||||
),
|
||||
],
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: onPickAndUpload,
|
||||
icon: const Icon(Symbols.upload_file),
|
||||
label: const Text('Upload Files'),
|
||||
),
|
||||
const Gap(12),
|
||||
OutlinedButton.icon(
|
||||
onPressed:
|
||||
() => onShowCreateDirectory(ref.context, currentPath),
|
||||
icon: const Icon(Symbols.create_new_folder),
|
||||
label: const Text('Create Directory'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -785,6 +789,7 @@ class FileListView extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'confirmDeleteFile'.tr(),
|
||||
'deleteFile'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -1153,6 +1158,7 @@ class FileListView extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'confirmDeleteFile'.tr(),
|
||||
'deleteFile'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -1221,6 +1227,7 @@ class FileListView extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'confirmDeleteFile'.tr(),
|
||||
'deleteFile'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -1263,6 +1270,7 @@ class FileListView extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'confirmDeleteFile'.tr(),
|
||||
'deleteFile'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/screens/posts/post_detail.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/post/compose_card.dart';
|
||||
@@ -50,19 +51,33 @@ class PostComposeSheet extends HookConsumerWidget {
|
||||
final restoredInitialState = useState<PostComposeInitialState?>(null);
|
||||
final prompted = useState(false);
|
||||
|
||||
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||
// Fetch full post data if we're editing a post
|
||||
final fullPostData =
|
||||
originalPost != null
|
||||
? ref.watch(postProvider(originalPost!.id))
|
||||
: const AsyncValue.data(null);
|
||||
|
||||
// Use the full post data if available, otherwise fall back to originalPost
|
||||
final effectiveOriginalPost = fullPostData.when(
|
||||
data: (fullPost) => fullPost ?? originalPost,
|
||||
loading: () => originalPost,
|
||||
error: (_, _) => originalPost,
|
||||
);
|
||||
|
||||
final repliedPost =
|
||||
initialState?.replyingTo ?? effectiveOriginalPost?.repliedPost;
|
||||
final forwardedPost =
|
||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||
initialState?.forwardingTo ?? effectiveOriginalPost?.forwardedPost;
|
||||
|
||||
// Create compose state
|
||||
final ComposeState state = useMemoized(
|
||||
() => ComposeLogic.createState(
|
||||
originalPost: originalPost,
|
||||
originalPost: effectiveOriginalPost,
|
||||
forwardedPost: forwardedPost,
|
||||
repliedPost: repliedPost,
|
||||
postType: 0,
|
||||
),
|
||||
[originalPost, forwardedPost, repliedPost],
|
||||
[effectiveOriginalPost, forwardedPost, repliedPost],
|
||||
);
|
||||
|
||||
// Add a listener to the entire state to trigger rebuilds
|
||||
@@ -112,7 +127,7 @@ class PostComposeSheet extends HookConsumerWidget {
|
||||
ref,
|
||||
state,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
originalPost: effectiveOriginalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
onSuccess: () {
|
||||
@@ -139,8 +154,13 @@ class PostComposeSheet extends HookConsumerWidget {
|
||||
height: 24,
|
||||
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(originalPost != null ? Symbols.edit : Symbols.upload),
|
||||
tooltip: originalPost != null ? 'postUpdate'.tr() : 'postPublish'.tr(),
|
||||
: Icon(
|
||||
effectiveOriginalPost != null ? Symbols.edit : Symbols.upload,
|
||||
),
|
||||
tooltip:
|
||||
effectiveOriginalPost != null
|
||||
? 'postUpdate'.tr()
|
||||
: 'postPublish'.tr(),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -148,7 +168,7 @@ class PostComposeSheet extends HookConsumerWidget {
|
||||
titleText: 'postCompose'.tr(),
|
||||
actions: actions,
|
||||
child: PostComposeCard(
|
||||
originalPost: originalPost,
|
||||
originalPost: effectiveOriginalPost,
|
||||
initialState: restoredInitialState.value ?? initialState,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: () {
|
||||
|
||||
@@ -122,6 +122,7 @@ class DraftManagerSheet extends HookConsumerWidget {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'clearAllDraftsConfirm'.tr(),
|
||||
'clearAllDrafts'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
|
||||
@@ -197,6 +197,7 @@ class PostActionableItem extends HookConsumerWidget {
|
||||
showConfirmAlert(
|
||||
'deletePostHint'.tr(),
|
||||
'deletePost'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
|
||||
@@ -69,22 +69,24 @@ class PostItemCreator extends HookConsumerWidget {
|
||||
title: 'delete'.tr(),
|
||||
image: MenuImage.icon(Symbols.delete),
|
||||
callback: () {
|
||||
showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then(
|
||||
(confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
.delete('/sphere/posts/${item.id}')
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
})
|
||||
.then((_) {
|
||||
onRefresh?.call();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
showConfirmAlert(
|
||||
'deletePostHint'.tr(),
|
||||
'deletePost'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
client
|
||||
.delete('/sphere/posts/${item.id}')
|
||||
.catchError((err) {
|
||||
showErrorAlert(err);
|
||||
return err;
|
||||
})
|
||||
.then((_) {
|
||||
onRefresh?.call();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
MenuSeparator(),
|
||||
|
||||
@@ -196,7 +196,7 @@ class PostReplyPreview extends HookConsumerWidget {
|
||||
: (featuredReply!).map(
|
||||
data:
|
||||
(data) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
@@ -177,8 +178,11 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
|
||||
// Show compose sheet
|
||||
if (mounted) {
|
||||
PostComposeSheet.show(context, initialState: initialState);
|
||||
Navigator.of(context).pop(); // Close the share sheet
|
||||
await PostComposeSheet.show(context, initialState: initialState);
|
||||
// Close the share sheet after the compose sheet is dismissed
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
@@ -281,23 +285,10 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
);
|
||||
|
||||
// Show navigation prompt
|
||||
final shouldNavigate = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text('shareSuccess'.tr()),
|
||||
content: Text('wouldYouLikeToGoToChat'.tr()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('no'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text('yes'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
final shouldNavigate = await showConfirmAlert(
|
||||
'wouldYouLikeToGoToChat'.tr(),
|
||||
'shareSuccess'.tr(),
|
||||
icon: Symbols.check_circle,
|
||||
);
|
||||
|
||||
// Close the share sheet
|
||||
@@ -363,6 +354,92 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadFiles() async {
|
||||
if (widget.content.files == null || widget.content.files!.isEmpty) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final universalFiles =
|
||||
widget.content.files!.map((file) {
|
||||
UniversalFileType fileType;
|
||||
if (file.mimeType?.startsWith('image/') == true) {
|
||||
fileType = UniversalFileType.image;
|
||||
} else if (file.mimeType?.startsWith('video/') == true) {
|
||||
fileType = UniversalFileType.video;
|
||||
} else if (file.mimeType?.startsWith('audio/') == true) {
|
||||
fileType = UniversalFileType.audio;
|
||||
} else {
|
||||
fileType = UniversalFileType.file;
|
||||
}
|
||||
return UniversalFile(data: file, type: fileType);
|
||||
}).toList();
|
||||
|
||||
// Initialize progress tracking
|
||||
final messageId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
_fileUploadProgress[messageId] = List.filled(universalFiles.length, 0.0);
|
||||
|
||||
List<SnCloudFile> uploadedFiles = [];
|
||||
|
||||
// Upload each file
|
||||
for (var idx = 0; idx < universalFiles.length; idx++) {
|
||||
final file = universalFiles[idx];
|
||||
final cloudFile =
|
||||
await FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
fileData: file,
|
||||
onProgress: (progress, _) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_fileUploadProgress[messageId]?[idx] = progress ?? 0.0;
|
||||
});
|
||||
}
|
||||
},
|
||||
).future;
|
||||
|
||||
if (cloudFile == null) {
|
||||
throw Exception('Failed to upload file: ${file.data.name}');
|
||||
}
|
||||
uploadedFiles.add(cloudFile);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
// Show success message
|
||||
showSnackBar('uploadSuccess'.tr());
|
||||
|
||||
// If single file, ask to view details
|
||||
if (uploadedFiles.length == 1) {
|
||||
final shouldView = await showConfirmAlert(
|
||||
'wouldYouLikeToViewFile'.tr(),
|
||||
'uploadSuccess'.tr(),
|
||||
icon: Symbols.check_circle,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close share sheet
|
||||
if (shouldView == true) {
|
||||
context.pushNamed(
|
||||
'fileDetail',
|
||||
pathParameters: {'id': uploadedFiles.first.id},
|
||||
extra: uploadedFiles.first,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just close for multiple files
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorAlert(e);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyToClipboard() async {
|
||||
try {
|
||||
String textToCopy = '';
|
||||
@@ -452,6 +529,15 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
onTap: _isLoading ? null : _shareToPost,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (widget.content.type ==
|
||||
ShareContentType.file) ...[
|
||||
_CompactShareOption(
|
||||
icon: Symbols.cloud_upload,
|
||||
title: 'upload'.tr(),
|
||||
onTap: _isLoading ? null : _uploadFiles,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
_CompactShareOption(
|
||||
icon: Symbols.content_copy,
|
||||
title: 'copy'.tr(),
|
||||
@@ -650,19 +736,26 @@ class _ChatRoomsList extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatRoomOption extends StatelessWidget {
|
||||
class _ChatRoomOption extends HookConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ChatRoomOption({required this.room, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
|
||||
final validMembers =
|
||||
(room.members ?? [])
|
||||
.where((m) => m.accountId != userInfo.value?.id)
|
||||
.toList();
|
||||
|
||||
final isDirect = room.type == 1; // Assuming type 1 is direct chat
|
||||
final displayName =
|
||||
room.name ??
|
||||
(isDirect && room.members != null
|
||||
? room.members!.map((m) => m.account.nick).join(', ')
|
||||
(isDirect
|
||||
? validMembers.map((m) => m.account.nick).join(', ')
|
||||
: 'unknownChat'.tr());
|
||||
|
||||
return GestureDetector(
|
||||
@@ -694,18 +787,22 @@ class _ChatRoomOption extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child:
|
||||
room.picture != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: CloudFileWidget(
|
||||
item: room.picture!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
(isDirect && room.picture?.id == null)
|
||||
? SplitAvatarWidget(
|
||||
filesId:
|
||||
validMembers
|
||||
.map((e) => e.account.profile.picture?.id)
|
||||
.toList(),
|
||||
radius: 16,
|
||||
)
|
||||
: Icon(
|
||||
isDirect ? Symbols.person : Symbols.group,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
: room.picture?.id == null
|
||||
? CircleAvatar(
|
||||
radius: 16,
|
||||
child: Text(room.name![0].toUpperCase()),
|
||||
)
|
||||
: ProfilePictureWidget(
|
||||
fileId: room.picture?.id,
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
@@ -72,26 +72,10 @@ class FileManagementActionSection extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Future<void> _purgeFiles(BuildContext context, WidgetRef ref) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text('confirmPurge'.tr()),
|
||||
content: Text('purgeFilesConfirm'.tr()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text('purgeAllFiles'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
final confirmed = await showConfirmAlert(
|
||||
'purgeFilesConfirm'.tr(),
|
||||
'confirmPurge'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
@@ -82,7 +82,7 @@ class SiteActionMenu extends HookConsumerWidget {
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/zone/sites/${site.id}');
|
||||
await client.delete('/zone/sites/$pubName/${site.slug}');
|
||||
if (context.mounted) {
|
||||
showSnackBar('siteDeletedSuccess'.tr());
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/publication_site.dart';
|
||||
import 'package:island/widgets/sites/file_management_section.dart';
|
||||
import 'package:island/widgets/sites/file_management_action_section.dart';
|
||||
import 'package:island/widgets/sites/info_row.dart';
|
||||
import 'package:island/widgets/sites/site_info_card.dart';
|
||||
import 'package:island/widgets/sites/pages_section.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:island/screens/creators/sites/site_detail.dart';
|
||||
|
||||
class SiteDetailContent extends HookConsumerWidget {
|
||||
@@ -24,8 +21,6 @@ class SiteDetailContent extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ExtendedRefreshIndicator(
|
||||
onRefresh:
|
||||
() async =>
|
||||
@@ -36,65 +31,7 @@ class SiteDetailContent extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Site Info Card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'siteInformation'.tr(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
InfoRow(
|
||||
label: 'name'.tr(),
|
||||
value: site.name,
|
||||
icon: Symbols.title,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'slug'.tr(),
|
||||
value: site.slug,
|
||||
icon: Symbols.tag,
|
||||
monospace: true,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Mode',
|
||||
value:
|
||||
site.mode == 0
|
||||
? 'siteModeFullyManaged'.tr()
|
||||
: 'siteModeSelfManaged'.tr(),
|
||||
icon: Symbols.settings,
|
||||
),
|
||||
if (site.description != null &&
|
||||
site.description!.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'description'.tr(),
|
||||
value: site.description!,
|
||||
icon: Symbols.description,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteCreated'.tr(),
|
||||
value: site.createdAt.formatSystem(),
|
||||
icon: Symbols.calendar_add_on,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteUpdated'.tr(),
|
||||
value: site.updatedAt.formatSystem(),
|
||||
icon: Symbols.update,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SiteInfoCard(site: site),
|
||||
const Gap(8),
|
||||
if (site.mode == 1) // Self-Managed only
|
||||
FileManagementActionSection(site: site, pubName: pubName),
|
||||
|
||||
85
lib/widgets/sites/site_info_card.dart
Normal file
85
lib/widgets/sites/site_info_card.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/models/publication_site.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/sites/info_row.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class SiteInfoCard extends StatelessWidget {
|
||||
final SnPublicationSite site;
|
||||
|
||||
const SiteInfoCard({super.key, required this.site});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'siteInformation'.tr(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
InfoRow(label: 'name'.tr(), value: site.name, icon: Symbols.title),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'slug'.tr(),
|
||||
value: site.slug,
|
||||
icon: Symbols.tag,
|
||||
monospace: true,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteDomain'.tr(),
|
||||
value: '${site.slug}.solian.page',
|
||||
icon: Symbols.globe,
|
||||
monospace: true,
|
||||
onTap: () {
|
||||
final url = 'https://${site.slug}.solian.page';
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteMode'.tr(),
|
||||
value:
|
||||
site.mode == 0
|
||||
? 'siteModeFullyManaged'.tr()
|
||||
: 'siteModeSelfManaged'.tr(),
|
||||
icon: Symbols.settings,
|
||||
),
|
||||
if (site.description != null && site.description!.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'description'.tr(),
|
||||
value: site.description!,
|
||||
icon: Symbols.description,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteCreated'.tr(),
|
||||
value: site.createdAt.formatSystem(),
|
||||
icon: Symbols.calendar_add_on,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteUpdated'.tr(),
|
||||
value: site.updatedAt.formatSystem(),
|
||||
icon: Symbols.update,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.3.0+147
|
||||
version: 3.4.0+149
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.2
|
||||
|
||||
Reference in New Issue
Block a user