Compare commits

...

27 Commits

Author SHA1 Message Date
5fc8859f3b 🚀 Launch 3.4.0+149 2025-11-25 00:06:45 +08:00
e30e7adbe2 🐛 Trying to fix NSE attachment 2025-11-25 00:05:07 +08:00
68be4db160 Able to upload from share 2025-11-25 00:00:04 +08:00
aa91e376ca 🐛 Fix bugs in the share sheet 2025-11-24 23:49:19 +08:00
caffb85588 Explore screen has a drop to share 2025-11-24 23:36:53 +08:00
521b192205 🐛 Fix edit post got truncated post lead to wrong state of editing, close #195 2025-11-24 23:28:43 +08:00
77ac0428ea 💄 The universal escape now can close the fade made dialog 2025-11-24 23:21:52 +08:00
88c8227c66 ♻️ Dangerous confirm dialog variant 2025-11-24 23:13:35 +08:00
b20d8350a8 💄 Alert max width 2025-11-24 23:01:29 +08:00
98b27bed0e 🐛 Fix list overlap with some UI element on the screen 2025-11-24 22:56:08 +08:00
3a7d8b1a0d 🐛 Fix file dashboard icon sometimes overflow 2025-11-24 22:51:22 +08:00
b4801d6af6 🐛 Fix site unable to delete, close #196 2025-11-24 22:50:24 +08:00
aab5b957af 🐛 Fix mobile site didn't show domain 2025-11-24 22:43:25 +08:00
43d706a184 💄 Adjust the comment row styling 2025-11-24 22:34:38 +08:00
98df275f88 🐛 Fix compose article unable to scroll close #194 2025-11-24 22:30:56 +08:00
5663df6ef1 🚀 Launch 3.3.0+148 2025-11-23 13:07:15 +08:00
e996a0c95f 👽 Update the verification mark 2025-11-23 13:04:07 +08:00
a090e93f57 🗑️ Remove the chat role display in message 2025-11-23 12:56:02 +08:00
c69034c071 Better notification list 2025-11-23 12:55:49 +08:00
369ea6cf5b 🐛 Fix unmounted setState 2025-11-23 12:46:49 +08:00
2e371b5296 💄 More accurate notification unread count 2025-11-23 12:45:19 +08:00
2e9d61bcfa Chat unread indicator across all chat 2025-11-23 12:40:52 +08:00
9c2b5b0dfa 🐛 Fix further remote messages will not be loaded 2025-11-23 12:25:46 +08:00
3b40f515b3 🐛 Fix file list go back to wrong page 2025-11-23 12:18:57 +08:00
5ee61dbef2 Pagination in chat message sync 2025-11-23 12:18:46 +08:00
b151ef6686 🐛 Try to fix message loading 2025-11-23 11:54:51 +08:00
ff934d0f08 💄 Update the captcha style 2025-11-23 11:39:52 +08:00
55 changed files with 929 additions and 445 deletions

View File

@@ -233,6 +233,9 @@
"pickFile": "Pick a file", "pickFile": "Pick a file",
"uploading": "Uploading", "uploading": "Uploading",
"uploadingProgress": "Uploading {} of {}", "uploadingProgress": "Uploading {} of {}",
"upload": "Upload",
"uploadSuccess": "Upload successful!",
"wouldYouLikeToViewFile": "Would you like to view the file?",
"uploadAll": "Upload All", "uploadAll": "Upload All",
"stickerCopyPlaceholder": "Copy Placeholder", "stickerCopyPlaceholder": "Copy Placeholder",
"realmSelection": "Select a Realm", "realmSelection": "Select a Realm",
@@ -1110,7 +1113,6 @@
"deleteRecycledFiles": "Delete Recycled Files", "deleteRecycledFiles": "Delete Recycled Files",
"recycledFilesDeleted": "Recycled files deleted successfully", "recycledFilesDeleted": "Recycled files deleted successfully",
"failedToDeleteRecycledFiles": "Failed to delete recycled files", "failedToDeleteRecycledFiles": "Failed to delete recycled files",
"upload": "Upload",
"updateAvailable": "Update available", "updateAvailable": "Update available",
"noChangelogProvided": "No changelog provided.", "noChangelogProvided": "No changelog provided.",
"useSecondarySourceForDownload": "Use secondary source for download", "useSecondarySourceForDownload": "Use secondary source for download",
@@ -1471,5 +1473,6 @@
"allFilesUploadedSuccess": "All files uploaded successfully", "allFilesUploadedSuccess": "All files uploaded successfully",
"lotteryLastNumberSpecial": "The last selected number will be your special number.", "lotteryLastNumberSpecial": "The last selected number will be your special number.",
"lotteryMultiplierRequired": "Please enter a multiplier", "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"
} }

View File

@@ -585,10 +585,10 @@
"unknownChat": "未知聊天", "unknownChat": "未知聊天",
"addAdditionalMessage": "添加附加消息……", "addAdditionalMessage": "添加附加消息……",
"uploadingFiles": "上传文件中……", "uploadingFiles": "上传文件中……",
"sharedSuccessfully": "分享成功", "sharedSuccessfully": "分享成功",
"shareSuccess": "分享成功", "shareSuccess": "分享成功",
"shareToSpecificChatSuccess": "成功分享至 {}", "shareToSpecificChatSuccess": "成功分享至 {}",
"wouldYouLikeToGoToChat": "是否前往该聊天?", "wouldYouLikeToGoToChat": "是否前往该聊天页面",
"no": "否", "no": "否",
"yes": "是", "yes": "是",
"navigateToChat": "前往聊天", "navigateToChat": "前往聊天",

View File

@@ -140,21 +140,29 @@ class NotificationService: UNNotificationServiceExtension {
guard !attachmentUrls.isEmpty else { guard !attachmentUrls.isEmpty else {
print("Invalid URLs for attachments: \(attachmentUrls)") print("Invalid URLs for attachments: \(attachmentUrls)")
self.contentHandler?(content)
return return
} }
let targetSize = 512 let targetSize = 512
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit) 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 { for attachmentUrl in attachmentUrls {
guard let remoteUrl = URL(string: attachmentUrl) else { guard let remoteUrl = URL(string: attachmentUrl) else {
print("Invalid URL for attachment: \(attachmentUrl)") 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 ? [ KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
.processor(scaleProcessor) .processor(scaleProcessor)
] : nil) { [weak self] result in ] : nil) { [weak self] result in
defer { dispatchGroup.leave() }
guard let self = self else { return } guard let self = self else { return }
switch result { switch result {
@@ -166,48 +174,33 @@ class NotificationService: UNNotificationServiceExtension {
do { do {
// Write the image data to a temporary file for UNNotificationAttachment // Write the image data to a temporary file for UNNotificationAttachment
try retrievalResult.image.pngData()?.write(to: cachedFileUrl) 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 { } catch {
print("Failed to write media to temporary file: \(error.localizedDescription)") print("Failed to write media to temporary file: \(error.localizedDescription)")
self.contentHandler?(content)
} }
case .failure(let error): case .failure(let error):
print("Failed to retrieve image: \(error.localizedDescription)") print("Failed to retrieve image: \(error.localizedDescription)")
}
}
}
dispatchGroup.notify(queue: .main) { [weak self] in
guard let self = self else { return }
content.attachments = attachments
self.contentHandler?(content) 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)
}
private func createMessageIntent(with sender: INPerson, meta: [AnyHashable: Any], body: String) -> INSendMessageIntent { private func createMessageIntent(with sender: INPerson, meta: [AnyHashable: Any], body: String) -> INSendMessageIntent {
INSendMessageIntent( INSendMessageIntent(

View File

@@ -449,4 +449,13 @@ class AppDatabase extends _$AppDatabase {
chatMembers, chatMembers,
).insert(companionFromMember(member), mode: InsertMode.insertOrReplace); ).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));
}
} }

View File

@@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$callNotifierHash() => r'2caee30f42315e539cb4df17c0d464ceed41ffa0'; String _$callNotifierHash() => r'ef4e3e9c9d411cf9dce1ceb456a3b866b2c87db3';
/// See also [CallNotifier]. /// See also [CallNotifier].
@ProviderFor(CallNotifier) @ProviderFor(CallNotifier)

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
@@ -6,6 +8,58 @@ import 'package:island/pods/chat/chat_subscribe.dart';
part 'chat_summary.g.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 @riverpod
class ChatSummary extends _$ChatSummary { class ChatSummary extends _$ChatSummary {
@override @override
@@ -41,6 +95,14 @@ class ChatSummary extends _$ChatSummary {
state.whenData((summaries) { state.whenData((summaries) {
final summary = summaries[chatId]; final summary = summaries[chatId];
if (summary != null) { if (summary != null) {
// Decrement global unread count
final unreadToDecrement = summary.unreadCount;
if (unreadToDecrement > 0) {
ref
.read(chatUnreadCountNotifierProvider.notifier)
.decrement(unreadToDecrement);
}
state = AsyncData({ state = AsyncData({
...summaries, ...summaries,
chatId: SnChatSummary( chatId: SnChatSummary(

View File

@@ -6,6 +6,24 @@ part of 'chat_summary.dart';
// RiverpodGenerator // 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'; String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
/// See also [ChatSummary]. /// See also [ChatSummary].

View File

@@ -45,6 +45,7 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _isSyncing = false; bool _isSyncing = false;
bool _isJumping = false; bool _isJumping = false;
bool _isUpdatingState = false; bool _isUpdatingState = false;
bool _allRemoteMessagesFetched = false;
DateTime? _lastPauseTime; DateTime? _lastPauseTime;
late final Future<SnAccount?> Function(String) _fetchAccount; late final Future<SnAccount?> Function(String) _fetchAccount;
@@ -278,6 +279,7 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
if (offset >= _totalCount!) { if (offset >= _totalCount!) {
_allRemoteMessagesFetched = true;
return []; return [];
} }
@@ -299,10 +301,7 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList(); }).toList();
for (final message in messages) { for (final message in messages) {
await _database.saveMessage(_database.messageToCompanion(message)); await _database.saveMessageWithSender(message);
if (message.sender != null) {
await _database.saveMember(message.sender!); // Save/update member data
}
if (message.nonce != null) { if (message.nonce != null) {
_pendingMessages.removeWhere( _pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == message.nonce, (_, 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; return messages;
} }
@@ -319,6 +323,7 @@ class MessagesNotifier extends _$MessagesNotifier {
return; return;
} }
_isSyncing = true; _isSyncing = true;
_allRemoteMessagesFetched = false;
talker.log('Starting message sync'); talker.log('Starting message sync');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true); Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
@@ -346,19 +351,48 @@ class MessagesNotifier extends _$MessagesNotifier {
return; return;
} }
// Sync with pagination support using timestamp-based cursor
int? totalMessages;
int syncedCount = 0;
int lastSyncTimestamp =
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch;
do {
final resp = await _apiClient.post( final resp = await _apiClient.post(
'/sphere/chat/${_room.id}/sync', '/sphere/chat/${_room.id}/sync',
data: { data: {'last_sync_timestamp': lastSyncTimestamp},
'last_sync_timestamp':
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
},
); );
// 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 response = MessageSyncResponse.fromJson(resp.data);
talker.log('Sync response: ${response.messages.length} changes'); final messagesCount = response.messages.length;
talker.log(
'Sync page: synced=$syncedCount/$totalMessages, count=$messagesCount',
);
for (final message in response.messages) { for (final message in response.messages) {
await receiveMessage(message); 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) { } catch (err, stackTrace) {
talker.log( talker.log(
'Error syncing messages', 'Error syncing messages',
@@ -397,14 +431,35 @@ class MessagesNotifier extends _$MessagesNotifier {
withAttachments: _withAttachments, 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; 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) { 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 { } else {
return []; // If searching, and no local messages, don't fetch from network // For search queries, return local only
return localMessages;
} }
} catch (e) { } catch (e) {
final localMessages = await _getCachedMessages( final localMessages = await _getCachedMessages(
@@ -424,6 +479,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> loadInitial() async { Future<void> loadInitial() async {
talker.log('Loading initial messages'); talker.log('Loading initial messages');
_allRemoteMessagesFetched = false;
if (_searchQuery == null || _searchQuery!.isEmpty) { if (_searchQuery == null || _searchQuery!.isEmpty) {
syncMessages(); syncMessages();
} }
@@ -445,6 +501,7 @@ class MessagesNotifier extends _$MessagesNotifier {
if (!_hasMore || state is AsyncLoading) return; if (!_hasMore || state is AsyncLoading) return;
talker.log('Loading more messages'); talker.log('Loading more messages');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
try { try {
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
final offset = currentMessages.length; final offset = currentMessages.length;
@@ -466,6 +523,10 @@ class MessagesNotifier extends _$MessagesNotifier {
stackTrace: stackTrace, stackTrace: stackTrace,
); );
showErrorAlert(err); showErrorAlert(err);
} finally {
Future.microtask(
() => ref.read(isSyncingProvider.notifier).state = false,
);
} }
} }
@@ -501,8 +562,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages[localMessage.id] = localMessage; _pendingMessages[localMessage.id] = localMessage;
_fileUploadProgress[localMessage.id] = {}; _fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage)); await _database.saveMessageWithSender(localMessage);
await _database.saveMember(mockMessage.sender);
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]); state = AsyncValue.data([localMessage, ...currentMessages]);
@@ -553,7 +613,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(localMessage.id); _pendingMessages.remove(localMessage.id);
await _database.deleteMessage(localMessage.id); await _database.deleteMessage(localMessage.id);
await _database.saveMessage(_database.messageToCompanion(updatedMessage)); await _database.saveMessageWithSender(updatedMessage);
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
if (editingTo != null) { if (editingTo != null) {
@@ -635,7 +695,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_pendingMessages.remove(pendingMessageId); _pendingMessages.remove(pendingMessageId);
await _database.deleteMessage(pendingMessageId); await _database.deleteMessage(pendingMessageId);
await _database.saveMessage(_database.messageToCompanion(updatedMessage)); await _database.saveMessageWithSender(updatedMessage);
final newMessages = final newMessages =
(state.value ?? []).map((m) { (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 currentMessages = state.value ?? [];
final existingIndex = currentMessages.indexWhere( final existingIndex = currentMessages.indexWhere(
@@ -789,7 +849,7 @@ class MessagesNotifier extends _$MessagesNotifier {
messageToUpdate.status, messageToUpdate.status,
); );
await _database.saveMessage(_database.messageToCompanion(deletedMessage)); await _database.saveMessageWithSender(deletedMessage);
if (messageIndex != -1) { if (messageIndex != -1) {
final newList = [...currentMessages]; final newList = [...currentMessages];
@@ -913,6 +973,7 @@ class MessagesNotifier extends _$MessagesNotifier {
_searchQuery = null; _searchQuery = null;
_withLinks = null; _withLinks = null;
_withAttachments = null; _withAttachments = null;
_allRemoteMessagesFetched = false;
loadInitial(); loadInitial();
} }
@@ -938,7 +999,7 @@ class MessagesNotifier extends _$MessagesNotifier {
MessageStatus.sent, MessageStatus.sent,
); );
await _database.saveMessage(_database.messageToCompanion(message)); await _database.saveMessageWithSender(message);
return message; return message;
} catch (e) { } catch (e) {
if (e is DioException) return null; if (e is DioException) return null;

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$messagesNotifierHash() => r'27e5d686d9204ba39adbd1838cf4a6eaea0ac85f'; String _$messagesNotifierHash() => r'27ce32c54e317a04e1d554ed4a70a24e4503fdd1';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -62,6 +62,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'accountDeletionHint'.tr(), 'accountDeletionHint'.tr(),
'accountDeletion'.tr(), 'accountDeletion'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {

View File

@@ -26,6 +26,7 @@ class AuthFactorSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'authFactorDeleteHint'.tr(), 'authFactorDeleteHint'.tr(),
'authFactorDelete'.tr(), 'authFactorDelete'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {

View File

@@ -82,6 +82,7 @@ class AccountConnectionSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'accountConnectionDeleteHint'.tr(), 'accountConnectionDeleteHint'.tr(),
'accountConnectionDelete'.tr(), 'accountConnectionDelete'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {
@@ -332,6 +333,7 @@ class AccountConnectionsSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'accountConnectionDeleteHint'.tr(), 'accountConnectionDeleteHint'.tr(),
'accountConnectionDelete'.tr(), 'accountConnectionDelete'.tr(),
isDanger: true,
); );
if (confirm && context.mounted) { if (confirm && context.mounted) {
try { try {

View File

@@ -20,6 +20,7 @@ class ContactMethodSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'contactMethodDeleteHint'.tr(), 'contactMethodDeleteHint'.tr(),
'contactMethodDelete'.tr(), 'contactMethodDelete'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {

View File

@@ -6,11 +6,12 @@ import 'package:island/widgets/content/sheet.dart';
class CaptchaScreen extends ConsumerWidget { class CaptchaScreen extends ConsumerWidget {
static Future<String?> show(BuildContext context) { static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>( return Navigator.push<String>(
context: context, context,
isScrollControlled: true, MaterialPageRoute(
isDismissible: false,
builder: (context) => const CaptchaScreen(), builder: (context) => const CaptchaScreen(),
fullscreenDialog: true,
),
); );
} }

View File

@@ -1,5 +1,3 @@
// ignore_for_file: invalid_runtime_check_with_js_interop_types
import 'dart:ui_web' as ui; import 'dart:ui_web' as ui;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
@@ -10,11 +8,12 @@ import 'package:flutter/material.dart';
class CaptchaScreen extends ConsumerStatefulWidget { class CaptchaScreen extends ConsumerStatefulWidget {
static Future<String?> show(BuildContext context) { static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>( return Navigator.push<String>(
context: context, context,
isDismissible: false, MaterialPageRoute(
isScrollControlled: true,
builder: (context) => const CaptchaScreen(), builder: (context) => const CaptchaScreen(),
fullscreenDialog: true,
),
); );
} }
@@ -29,7 +28,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
void _setupWebListener(String serverUrl) async { void _setupWebListener(String serverUrl) async {
web.window.onMessage.listen((event) { web.window.onMessage.listen((event) {
// ignore: invalid_runtime_check_with_js_interop_types
if (event.data != null && event.data is String) { if (event.data != null && event.data is String) {
// ignore: invalid_runtime_check_with_js_interop_types
final message = event.data as String; final message = event.data as String;
if (message.startsWith("captcha_tk=")) { if (message.startsWith("captcha_tk=")) {
String token = message.replaceFirst("captcha_tk=", ""); String token = message.replaceFirst("captcha_tk=", "");

View File

@@ -11,7 +11,6 @@ import 'package:island/models/chat.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/pods/database.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/chat/chat_summary.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
@@ -28,6 +27,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
part 'chat.g.dart'; part 'chat.g.dart';
@@ -289,7 +289,6 @@ class ChatListBodyWidget extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final chats = ref.watch(chatroomsJoinedProvider); final chats = ref.watch(chatroomsJoinedProvider);
final callState = ref.watch(callNotifierProvider);
Widget bodyWidget = Column( Widget bodyWidget = Column(
children: [ children: [
@@ -314,10 +313,8 @@ class ChatListBodyWidget extends HookConsumerWidget {
() => Future.sync(() { () => Future.sync(() {
ref.invalidate(chatroomsJoinedProvider); ref.invalidate(chatroomsJoinedProvider);
}), }),
child: ListView.builder( child: SuperListView.builder(
padding: EdgeInsets.only( padding: EdgeInsets.only(bottom: 96),
bottom: callState.isConnected ? 96 : 0,
),
itemCount: itemCount:
items items
.where( .where(

View File

@@ -487,6 +487,7 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteChatRoomHint'.tr(), 'deleteChatRoomHint'.tr(),
'deleteChatRoom'.tr(), 'deleteChatRoom'.tr(),
isDanger: true,
).then((confirm) async { ).then((confirm) async {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -304,16 +304,18 @@ class CreatorHubScreen extends HookConsumerWidget {
} }
void deletePublisher() { void deletePublisher() {
showConfirmAlert('deletePublisherHint'.tr(), 'deletePublisher'.tr()).then( showConfirmAlert(
(confirm) { 'deletePublisherHint'.tr(),
'deletePublisher'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
client.delete('/sphere/publishers/${currentPublisher.value!.name}'); client.delete('/sphere/publishers/${currentPublisher.value!.name}');
ref.invalidate(publishersManagedProvider); ref.invalidate(publishersManagedProvider);
currentPublisher.value = null; currentPublisher.value = null;
} }
}, });
);
} }
final List<DropdownMenuItem<SnPublisher>> publishersMenu = publishers.when( final List<DropdownMenuItem<SnPublisher>> publishersMenu = publishers.when(

View File

@@ -8,18 +8,16 @@ import 'package:island/pods/site_pages.dart';
import 'package:island/widgets/sites/page_form.dart'; import 'package:island/widgets/sites/page_form.dart';
import 'package:island/widgets/sites/site_action_menu.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_detail_content.dart';
import 'package:island/widgets/sites/site_info_card.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.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/pages_section.dart';
import 'package:island/widgets/sites/file_management_section.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/file_management_action_section.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
part 'site_detail.g.dart'; part 'site_detail.g.dart';
@@ -67,7 +65,6 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
), ),
body: siteAsync.when( body: siteAsync.when(
data: (site) { data: (site) {
final theme = Theme.of(context);
if (isWideScreen(context)) { if (isWideScreen(context)) {
return ExtendedRefreshIndicator( return ExtendedRefreshIndicator(
onRefresh: onRefresh:
@@ -99,76 +96,7 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Card( SiteInfoCard(site: site),
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,
),
],
),
),
),
const Gap(8), const Gap(8),
if (site.mode == 1) // Self-Managed only if (site.mode == 1) // Self-Managed only
FileManagementActionSection( FileManagementActionSection(

View File

@@ -190,6 +190,7 @@ class SiteForm extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'publicationSiteDeleteConfirm'.tr(), 'publicationSiteDeleteConfirm'.tr(),
'deletePublicationSite'.tr(), 'deletePublicationSite'.tr(),
isDanger: true,
); );
if (confirmed != true) return; if (confirmed != true) return;

View File

@@ -221,7 +221,9 @@ class _CreatorSiteItem extends HookConsumerWidget {
if (confirmed == true) { if (confirmed == true) {
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/${site.id}'); await client.delete(
'/zone/sites/$pubName/${site.slug}',
);
ref.invalidate(siteListNotifierProvider(pubName)); ref.invalidate(siteListNotifierProvider(pubName));
showSnackBar('siteDeletedSuccess'.tr()); showSnackBar('siteDeletedSuccess'.tr());
} catch (e) { } catch (e) {

View File

@@ -288,6 +288,7 @@ class StickerPackActionMenu extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteStickerPackHint'.tr(), 'deleteStickerPackHint'.tr(),
'deleteStickerPack'.tr(), 'deleteStickerPack'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -70,6 +70,7 @@ class WebfeedForm extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'Are you sure you want to delete this web feed? This action cannot be undone.', 'Are you sure you want to delete this web feed? This action cannot be undone.',
'Delete Web Feed', 'Delete Web Feed',
isDanger: true,
); );
if (confirmed != true) return; if (confirmed != true) return;

View File

@@ -211,6 +211,7 @@ class AppSecretsScreen extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteSecretHint'.tr(), 'deleteSecretHint'.tr(),
'deleteSecret'.tr(), 'deleteSecret'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);

View File

@@ -231,6 +231,7 @@ class CustomAppsScreen extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteCustomAppHint'.tr(), 'deleteCustomAppHint'.tr(),
'deleteCustomApp'.tr(), 'deleteCustomApp'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read( final client = ref.read(

View File

@@ -159,9 +159,11 @@ class BotKeysScreen extends HookConsumerWidget {
} }
void revokeKey(String keyId) { void revokeKey(String keyId) {
showConfirmAlert('revokeBotKeyHint'.tr(), 'revokeBotKey'.tr()).then(( showConfirmAlert(
confirm, 'revokeBotKeyHint'.tr(),
) { 'revokeBotKey'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
client client

View File

@@ -172,6 +172,7 @@ class BotsScreen extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteBotHint'.tr(), 'deleteBotHint'.tr(),
'deleteBot'.tr(), 'deleteBot'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);

View File

@@ -631,6 +631,7 @@ class _ProjectListTile extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteProjectHint'.tr(), 'deleteProjectHint'.tr(),
'deleteProject'.tr(), 'deleteProject'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);

View File

@@ -1,3 +1,4 @@
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -30,7 +31,9 @@ import 'package:island/widgets/publisher/publisher_card.dart';
import 'package:island/widgets/web_article_card.dart'; import 'package:island/widgets/web_article_card.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/services/event_bus.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:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
part 'explore.g.dart'; part 'explore.g.dart';
@@ -239,7 +242,25 @@ class ExploreScreen extends HookConsumerWidget {
final appBar = isWide ? null : _buildAppBar(tabController, context); final appBar = isWide ? null : _buildAppBar(tabController, context);
return AppScaffold( 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, isNoBackground: false,
appBar: appBar, appBar: appBar,
body: body:
@@ -256,6 +277,39 @@ class ExploreScreen extends HookConsumerWidget {
currentFilter.value, currentFilter.value,
) )
: _buildNarrowBody(context, ref, 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 height = type == 'post' ? 280.0 : 180.0;
final contentWidget = switch (type) { final contentWidget = switch (type) {
'post' => ListView.separated( 'post' => SuperListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: items.length, itemCount: items.length,
separatorBuilder: (context, index) => const Gap(12), separatorBuilder: (context, index) => const Gap(12),

View File

@@ -36,7 +36,7 @@ class FileListScreen extends HookConsumerWidget {
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: Text('files').tr(), title: Text('files').tr(),
leading: const PageBackButton(), leading: const PageBackButton(backTo: '/account'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.bar_chart), icon: const Icon(Symbols.bar_chart),

View File

@@ -10,6 +10,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/widgets/alert.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/markdown.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
@@ -68,6 +69,16 @@ class NotificationUnreadCountNotifier
void clear() async { void clear() async {
state = AsyncData(0); 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 @riverpod
@@ -115,8 +126,36 @@ class NotificationListNotifier extends _$NotificationListNotifier
class NotificationSheet extends HookConsumerWidget { class NotificationSheet extends HookConsumerWidget {
const NotificationSheet({super.key}); 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 @override
Widget build(BuildContext context, WidgetRef ref) { 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 { Future<void> markAllRead() async {
showLoadingModal(context); showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
@@ -149,12 +188,30 @@ class NotificationSheet extends HookConsumerWidget {
} }
final notification = data.items[index]; 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( return ListTile(
isThreeLine: true, isThreeLine: true,
contentPadding: EdgeInsets.symmetric( contentPadding: EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, 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), title: Text(notification.title),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -187,6 +244,29 @@ class NotificationSheet extends HookConsumerWidget {
).colorScheme.onSurface.withOpacity(0.8), ).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: trailing:

View File

@@ -256,7 +256,9 @@ class ArticleComposeScreen extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ComposeFormFields( Expanded(
child: SingleChildScrollView(
child: ComposeFormFields(
state: state, state: state,
showPublisherAvatar: false, showPublisherAvatar: false,
onPublisherTap: () { onPublisherTap: () {
@@ -271,6 +273,8 @@ class ArticleComposeScreen extends HookConsumerWidget {
}); });
}, },
).padding(top: 16), ).padding(top: 16),
),
),
// Attachments preview // Attachments preview
ValueListenableBuilder<List<UniversalFile>>( ValueListenableBuilder<List<UniversalFile>>(

View File

@@ -145,9 +145,11 @@ class PostActionButtons extends HookConsumerWidget {
message: 'delete'.tr(), message: 'delete'.tr(),
child: FilledButton.tonal( child: FilledButton.tonal(
onPressed: () { onPressed: () {
showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then(( showConfirmAlert(
confirm, 'deletePostHint'.tr(),
) { 'deletePost'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
client client

View File

@@ -427,6 +427,7 @@ class _RealmActionMenu extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deleteRealmHint'.tr(), 'deleteRealmHint'.tr(),
'deleteRealm'.tr(), 'deleteRealm'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -14,6 +14,7 @@ import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/chat/chat_summary.dart';
final currentRouteProvider = StateProvider<String?>((ref) => null); final currentRouteProvider = StateProvider<String?>((ref) => null);
@@ -50,6 +51,8 @@ class TabsScreen extends HookConsumerWidget {
notificationUnreadCountNotifierProvider, notificationUnreadCountNotifierProvider,
); );
final chatUnreadCount = ref.watch(chatUnreadCountNotifierProvider);
final wideScreen = isWideScreen(context); final wideScreen = isWideScreen(context);
final destinations = [ final destinations = [
@@ -59,7 +62,11 @@ class TabsScreen extends HookConsumerWidget {
), ),
NavigationDestination( NavigationDestination(
label: 'chat'.tr(), 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( NavigationDestination(
label: 'realms'.tr(), label: 'realms'.tr(),

View File

@@ -150,6 +150,7 @@ class AccountSessionSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'authDeviceLogoutHint'.tr(), 'authDeviceLogoutHint'.tr(),
'authDeviceLogout'.tr(), 'authDeviceLogout'.tr(),
isDanger: true,
); );
if (!confirm || !context.mounted) return; if (!confirm || !context.mounted) return;
try { try {
@@ -276,6 +277,7 @@ class AccountSessionSheet extends HookConsumerWidget {
final confirm = await showConfirmAlert( final confirm = await showConfirmAlert(
'authDeviceLogoutHint'.tr(), 'authDeviceLogoutHint'.tr(),
'authDeviceLogout'.tr(), 'authDeviceLogout'.tr(),
isDanger: true,
); );
if (confirm && context.mounted) { if (confirm && context.mounted) {
try { try {

View File

@@ -25,12 +25,24 @@ const Map<String, Color> kUsernamePlainColors = {
'white': Colors.white, '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.teal,
Colors.blue,
Colors.amber,
Colors.blueGrey,
Colors.lightBlue, Colors.lightBlue,
Colors.indigo,
Colors.red,
Colors.orange,
Colors.blue,
Colors.blueAccent,
]; ];
class AccountName extends StatelessWidget { class AccountName extends StatelessWidget {
@@ -291,13 +303,14 @@ class VerificationMark extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final icon = Icon( final icon = Icon(
mark.type == 4 (kVerificationMarkIcons.length > mark.type && mark.type >= 0)
? Symbols.play_circle ? kVerificationMarkIcons[mark.type]
: mark.type == 0
? Symbols.build_circle
: Symbols.verified, : Symbols.verified,
size: 16, size: 16,
color: kVerificationMarkColors[mark.type], color:
(kVerificationMarkColors.length > mark.type && mark.type >= 0)
? kVerificationMarkColors[mark.type]
: Colors.blue,
fill: 1, fill: 1,
); );
@@ -394,13 +407,14 @@ class VerificationStatusCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Icon( Icon(
mark.type == 4 (kVerificationMarkIcons.length > mark.type && mark.type >= 0)
? Symbols.play_circle ? kVerificationMarkIcons[mark.type]
: mark.type == 0
? Symbols.build_circle
: Symbols.verified, : Symbols.verified,
size: 32, size: 32,
color: kVerificationMarkColors[mark.type], color:
(kVerificationMarkColors.length > mark.type && mark.type >= 0)
? kVerificationMarkColors[mark.type]
: Colors.blue,
fill: 1, fill: 1,
).alignment(Alignment.centerLeft), ).alignment(Alignment.centerLeft),
const Gap(8), const Gap(8),

View File

@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/main.dart'; import 'package:island/main.dart';
import 'package:island/talker.dart'; import 'package:island/talker.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart'; import 'package:top_snackbar_flutter/top_snack_bar.dart';
@@ -156,6 +157,9 @@ String _parseRemoteError(DioException err) {
return message ?? err.toString(); return message ?? err.toString();
} }
// Track active overlay dialogs for dismissal
final List<void Function()> _activeOverlayDialogs = [];
Future<T?> showOverlayDialog<T>({ Future<T?> showOverlayDialog<T>({
required Widget Function(BuildContext context, void Function(T? result) close) required Widget Function(BuildContext context, void Function(T? result) close)
builder, builder,
@@ -174,6 +178,7 @@ Future<T?> showOverlayDialog<T>({
} }
entry.remove(); entry.remove();
_activeOverlayDialogs.remove(close);
completer.complete(result); completer.complete(result);
} }
@@ -214,11 +219,24 @@ Future<T?> showOverlayDialog<T>({
), ),
); );
_activeOverlayDialogs.add(() => close(null));
globalOverlay.currentState!.insert(entry); globalOverlay.currentState!.insert(entry);
return completer.future; 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) { if (err is Error) {
talker.error('Something went wrong...', err, err.stackTrace); talker.error('Something went wrong...', err, err.stackTrace);
} }
@@ -231,9 +249,30 @@ void showErrorAlert(dynamic err) {
showOverlayDialog<void>( showOverlayDialog<void>(
builder: builder:
(context, close) => AlertDialog( (context, close) => ConstrainedBox(
title: Text('somethingWentWrong'.tr()), constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
content: Text(text), 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: [ actions: [
TextButton( TextButton(
onPressed: () => close(null), onPressed: () => close(null),
@@ -241,15 +280,36 @@ void showErrorAlert(dynamic err) {
), ),
], ],
), ),
),
); );
} }
void showInfoAlert(String message, String title) { void showInfoAlert(String message, String title, {IconData? icon}) {
showOverlayDialog<void>( showOverlayDialog<void>(
builder: builder:
(context, close) => AlertDialog( (context, close) => ConstrainedBox(
title: Text(title), constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
content: Text(message), 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: [ actions: [
TextButton( TextButton(
onPressed: () => close(null), onPressed: () => close(null),
@@ -257,26 +317,61 @@ void showInfoAlert(String message, String title) {
), ),
], ],
), ),
),
); );
} }
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>( final result = await showOverlayDialog<bool>(
builder: builder:
(context, close) => AlertDialog( (context, close) => ConstrainedBox(
title: Text(title), constraints: const BoxConstraints(maxWidth: kDialogMaxWidth),
content: Text(message), 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),
],
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => close(false), onPressed: () => close(false),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel), child: Text(
MaterialLocalizations.of(context).cancelButtonLabel,
),
), ),
TextButton( TextButton(
onPressed: () => close(true), onPressed: () => close(true),
style:
isDanger
? TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
)
: null,
child: Text(MaterialLocalizations.of(context).okButtonLabel), child: Text(MaterialLocalizations.of(context).okButtonLabel),
), ),
], ],
), ),
),
); );
return result ?? false; return result ?? false;
} }

View File

@@ -13,6 +13,7 @@ import 'package:island/route.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/upload_overlay.dart'; import 'package:island/widgets/upload_overlay.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -364,6 +365,12 @@ class PopAction extends Action<PopIntent> {
@override @override
void invoke(PopIntent intent) { 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()) { if (ref.watch(routerProvider).canPop()) {
ref.read(routerProvider).pop(); ref.read(routerProvider).pop();
} }

View File

@@ -596,6 +596,7 @@ class MessageHoverActionMenu extends StatelessWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'deleteMessageConfirmation'.tr(), 'deleteMessageConfirmation'.tr(),
'deleteMessage'.tr(), 'deleteMessage'.tr(),
isDanger: true,
); );
if (confirmed) { if (confirmed) {

View File

@@ -76,8 +76,6 @@ class MessageSenderInfo extends StatelessWidget {
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
AccountName( AccountName(
account: sender.account, account: sender.account,
@@ -86,19 +84,6 @@ class MessageSenderInfo extends StatelessWidget {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
const SizedBox(width: 4),
Badge(
label:
Text(
sender.role >= 100
? 'permissionOwner'
: sender.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
),
],
),
Text( Text(
timestamp, timestamp,
style: TextStyle( style: TextStyle(

View File

@@ -55,16 +55,20 @@ class _EmbedLinkWidgetState extends State<EmbedLinkWidget> {
stream.removeListener(listener); stream.removeListener(listener);
final aspectRatio = info.image.width / info.image.height; final aspectRatio = info.image.width / info.image.height;
if (mounted) {
setState(() { setState(() {
_isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1; _isSquare = aspectRatio >= 0.9 && aspectRatio <= 1.1;
}); });
}
} catch (e) { } catch (e) {
// If error, assume not square // If error, assume not square
if (mounted) {
setState(() { setState(() {
_isSquare = false; _isSquare = false;
}); });
} }
} }
}
Future<void> _launchUrl() async { Future<void> _launchUrl() async {
final uri = Uri.parse(widget.link.url); final uri = Uri.parse(widget.link.url);

View File

@@ -15,6 +15,7 @@ class NetworkStatusSheet extends HookConsumerWidget {
final wsState = ref.watch(websocketStateProvider); final wsState = ref.watch(websocketStateProvider);
return SheetScaffold( return SheetScaffold(
heightFactor: 0.4,
titleText: titleText:
wsState == WebSocketState.connected() wsState == WebSocketState.connected()
? 'Connection Status' ? 'Connection Status'

View File

@@ -512,6 +512,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'Are you sure you want to delete the selected files?', 'Are you sure you want to delete the selected files?',
'Delete Selected Files', 'Delete Selected Files',
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;
if (context.mounted) { if (context.mounted) {
@@ -742,7 +743,9 @@ class FileListView extends HookConsumerWidget {
), ),
), ),
const Gap(16), const Gap(16),
Row( SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
ElevatedButton.icon( ElevatedButton.icon(
@@ -759,6 +762,7 @@ class FileListView extends HookConsumerWidget {
), ),
], ],
), ),
),
], ],
), ),
), ),
@@ -785,6 +789,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(), 'confirmDeleteFile'.tr(),
'deleteFile'.tr(), 'deleteFile'.tr(),
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;
@@ -1153,6 +1158,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(), 'confirmDeleteFile'.tr(),
'deleteFile'.tr(), 'deleteFile'.tr(),
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;
@@ -1221,6 +1227,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(), 'confirmDeleteFile'.tr(),
'deleteFile'.tr(), 'deleteFile'.tr(),
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;
@@ -1263,6 +1270,7 @@ class FileListView extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'confirmDeleteFile'.tr(), 'confirmDeleteFile'.tr(),
'deleteFile'.tr(), 'deleteFile'.tr(),
isDanger: true,
); );
if (!confirmed) return; if (!confirmed) return;

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/screens/posts/compose.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/services/compose_storage_db.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart'; import 'package:island/widgets/post/compose_card.dart';
@@ -50,19 +51,33 @@ class PostComposeSheet extends HookConsumerWidget {
final restoredInitialState = useState<PostComposeInitialState?>(null); final restoredInitialState = useState<PostComposeInitialState?>(null);
final prompted = useState(false); 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 = final forwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost; initialState?.forwardingTo ?? effectiveOriginalPost?.forwardedPost;
// Create compose state // Create compose state
final ComposeState state = useMemoized( final ComposeState state = useMemoized(
() => ComposeLogic.createState( () => ComposeLogic.createState(
originalPost: originalPost, originalPost: effectiveOriginalPost,
forwardedPost: forwardedPost, forwardedPost: forwardedPost,
repliedPost: repliedPost, repliedPost: repliedPost,
postType: 0, postType: 0,
), ),
[originalPost, forwardedPost, repliedPost], [effectiveOriginalPost, forwardedPost, repliedPost],
); );
// Add a listener to the entire state to trigger rebuilds // Add a listener to the entire state to trigger rebuilds
@@ -112,7 +127,7 @@ class PostComposeSheet extends HookConsumerWidget {
ref, ref,
state, state,
context, context,
originalPost: originalPost, originalPost: effectiveOriginalPost,
repliedPost: repliedPost, repliedPost: repliedPost,
forwardedPost: forwardedPost, forwardedPost: forwardedPost,
onSuccess: () { onSuccess: () {
@@ -139,8 +154,13 @@ class PostComposeSheet extends HookConsumerWidget {
height: 24, height: 24,
child: const CircularProgressIndicator(strokeWidth: 2), child: const CircularProgressIndicator(strokeWidth: 2),
) )
: Icon(originalPost != null ? Symbols.edit : Symbols.upload), : Icon(
tooltip: originalPost != null ? 'postUpdate'.tr() : 'postPublish'.tr(), effectiveOriginalPost != null ? Symbols.edit : Symbols.upload,
),
tooltip:
effectiveOriginalPost != null
? 'postUpdate'.tr()
: 'postPublish'.tr(),
), ),
]; ];
@@ -148,7 +168,7 @@ class PostComposeSheet extends HookConsumerWidget {
titleText: 'postCompose'.tr(), titleText: 'postCompose'.tr(),
actions: actions, actions: actions,
child: PostComposeCard( child: PostComposeCard(
originalPost: originalPost, originalPost: effectiveOriginalPost,
initialState: restoredInitialState.value ?? initialState, initialState: restoredInitialState.value ?? initialState,
onCancel: () => Navigator.of(context).pop(), onCancel: () => Navigator.of(context).pop(),
onSubmit: () { onSubmit: () {

View File

@@ -122,6 +122,7 @@ class DraftManagerSheet extends HookConsumerWidget {
final confirmed = await showConfirmAlert( final confirmed = await showConfirmAlert(
'clearAllDraftsConfirm'.tr(), 'clearAllDraftsConfirm'.tr(),
'clearAllDrafts'.tr(), 'clearAllDrafts'.tr(),
isDanger: true,
); );
if (confirmed == true) { if (confirmed == true) {

View File

@@ -197,6 +197,7 @@ class PostActionableItem extends HookConsumerWidget {
showConfirmAlert( showConfirmAlert(
'deletePostHint'.tr(), 'deletePostHint'.tr(),
'deletePost'.tr(), 'deletePost'.tr(),
isDanger: true,
).then((confirm) { ).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);

View File

@@ -69,8 +69,11 @@ class PostItemCreator extends HookConsumerWidget {
title: 'delete'.tr(), title: 'delete'.tr(),
image: MenuImage.icon(Symbols.delete), image: MenuImage.icon(Symbols.delete),
callback: () { callback: () {
showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then( showConfirmAlert(
(confirm) { 'deletePostHint'.tr(),
'deletePost'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) { if (confirm) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
client client
@@ -83,8 +86,7 @@ class PostItemCreator extends HookConsumerWidget {
onRefresh?.call(); onRefresh?.call();
}); });
} }
}, });
);
}, },
), ),
MenuSeparator(), MenuSeparator(),

View File

@@ -196,7 +196,7 @@ class PostReplyPreview extends HookConsumerWidget {
: (featuredReply!).map( : (featuredReply!).map(
data: data:
(data) => Row( (data) => Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8, spacing: 8,
children: [ children: [
ProfilePictureWidget( ProfilePictureWidget(

View File

@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/services/file_uploader.dart'; import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
@@ -177,8 +178,11 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
// Show compose sheet // Show compose sheet
if (mounted) { if (mounted) {
PostComposeSheet.show(context, initialState: initialState); await PostComposeSheet.show(context, initialState: initialState);
Navigator.of(context).pop(); // Close the share sheet // Close the share sheet after the compose sheet is dismissed
if (mounted) {
Navigator.of(context).pop();
}
} }
} catch (e) { } catch (e) {
showErrorAlert(e); showErrorAlert(e);
@@ -281,23 +285,10 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
); );
// Show navigation prompt // Show navigation prompt
final shouldNavigate = await showDialog<bool>( final shouldNavigate = await showConfirmAlert(
context: context, 'wouldYouLikeToGoToChat'.tr(),
builder: 'shareSuccess'.tr(),
(context) => AlertDialog( icon: Symbols.check_circle,
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()),
),
],
),
); );
// Close the share sheet // 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 { Future<void> _copyToClipboard() async {
try { try {
String textToCopy = ''; String textToCopy = '';
@@ -452,6 +529,15 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
onTap: _isLoading ? null : _shareToPost, onTap: _isLoading ? null : _shareToPost,
), ),
const SizedBox(width: 12), 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( _CompactShareOption(
icon: Symbols.content_copy, icon: Symbols.content_copy,
title: 'copy'.tr(), title: 'copy'.tr(),
@@ -650,19 +736,26 @@ class _ChatRoomsList extends ConsumerWidget {
} }
} }
class _ChatRoomOption extends StatelessWidget { class _ChatRoomOption extends HookConsumerWidget {
final SnChatRoom room; final SnChatRoom room;
final VoidCallback? onTap; final VoidCallback? onTap;
const _ChatRoomOption({required this.room, this.onTap}); const _ChatRoomOption({required this.room, this.onTap});
@override @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 isDirect = room.type == 1; // Assuming type 1 is direct chat
final displayName = final displayName =
room.name ?? room.name ??
(isDirect && room.members != null (isDirect
? room.members!.map((m) => m.account.nick).join(', ') ? validMembers.map((m) => m.account.nick).join(', ')
: 'unknownChat'.tr()); : 'unknownChat'.tr());
return GestureDetector( return GestureDetector(
@@ -694,18 +787,22 @@ class _ChatRoomOption extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: child:
room.picture != null (isDirect && room.picture?.id == null)
? ClipRRect( ? SplitAvatarWidget(
borderRadius: BorderRadius.circular(16), filesId:
child: CloudFileWidget( validMembers
item: room.picture!, .map((e) => e.account.profile.picture?.id)
fit: BoxFit.cover, .toList(),
), radius: 16,
) )
: Icon( : room.picture?.id == null
isDirect ? Symbols.person : Symbols.group, ? CircleAvatar(
size: 20, radius: 16,
color: Theme.of(context).colorScheme.primary, child: Text(room.name![0].toUpperCase()),
)
: ProfilePictureWidget(
fileId: room.picture?.id,
radius: 16,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),

View File

@@ -72,26 +72,10 @@ class FileManagementActionSection extends HookConsumerWidget {
} }
Future<void> _purgeFiles(BuildContext context, WidgetRef ref) async { Future<void> _purgeFiles(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>( final confirmed = await showConfirmAlert(
context: context, 'purgeFilesConfirm'.tr(),
builder: 'confirmPurge'.tr(),
(context) => AlertDialog( isDanger: true,
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()),
),
],
),
); );
if (confirmed != true) return; if (confirmed != true) return;

View File

@@ -82,7 +82,7 @@ class SiteActionMenu extends HookConsumerWidget {
if (confirmed == true) { if (confirmed == true) {
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/${site.id}'); await client.delete('/zone/sites/$pubName/${site.slug}');
if (context.mounted) { if (context.mounted) {
showSnackBar('siteDeletedSuccess'.tr()); showSnackBar('siteDeletedSuccess'.tr());
Navigator.of(context).pop(); Navigator.of(context).pop();

View File

@@ -1,15 +1,12 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart'; import 'package:island/models/publication_site.dart';
import 'package:island/widgets/sites/file_management_section.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/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/widgets/sites/pages_section.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/extended_refresh_indicator.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'; import 'package:island/screens/creators/sites/site_detail.dart';
class SiteDetailContent extends HookConsumerWidget { class SiteDetailContent extends HookConsumerWidget {
@@ -24,8 +21,6 @@ class SiteDetailContent extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return ExtendedRefreshIndicator( return ExtendedRefreshIndicator(
onRefresh: onRefresh:
() async => () async =>
@@ -36,65 +31,7 @@ class SiteDetailContent extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Site Info Card // Site Info Card
Card( SiteInfoCard(site: site),
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,
),
],
),
),
),
const Gap(8), const Gap(8),
if (site.mode == 1) // Self-Managed only if (site.mode == 1) // Self-Managed only
FileManagementActionSection(site: site, pubName: pubName), FileManagementActionSection(site: site, pubName: pubName),

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

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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 3.3.0+147 version: 3.4.0+149
environment: environment:
sdk: ^3.7.2 sdk: ^3.7.2