Compare commits

...

55 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
abe5ded896 🚀 Launch 3.3.0+147 2025-11-23 02:01:00 +08:00
f1d72a5215 🐛 Fix android build no check 2025-11-23 02:00:13 +08:00
864cbe73b7 🐛 Try to fix share intent fails 2025-11-23 01:54:01 +08:00
108a6da074 🌐 Localized files 2025-11-23 01:43:54 +08:00
f9a09599c9 ⬆️ Upgrade dependecies 2025-11-23 01:23:43 +08:00
9067dadd3e 🐛 Fix reaction sheet popover goes out of the screen 2025-11-23 01:21:54 +08:00
09f8df1e78 💄 Optimize design of the call content 2025-11-23 01:12:04 +08:00
2c5f246c55 💄 Redesign the video of the call 2025-11-23 00:53:00 +08:00
a66c6ea654 💫 Animated call overlay 2025-11-23 00:35:42 +08:00
3ad4bb4518 ♻️ Rebuild the call 2025-11-23 00:26:40 +08:00
53f0dcb825 Optimize performance for message item 2025-11-22 20:46:41 +08:00
557f5a2389 👔 Hide the friends overview on mobile 2025-11-22 20:26:41 +08:00
78f14f890f 💄 Optimize embedded section of chat input 2025-11-22 20:11:01 +08:00
77b2effb34 💫 Update the animation of alert's dialog 2025-11-22 19:18:42 +08:00
f02b4abf65 💄 Optimize audio player height 2025-11-22 18:58:25 +08:00
3f37c4f761 ♻️ Remove platform alert and use flutter dialog instead 2025-11-22 18:56:18 +08:00
5deb910fa4 ♻️ Refactored all ScaffoldMessager to use unifined snackbar API 2025-11-22 18:42:12 +08:00
f50a19f573 🐛 Dozens of bug fixes 2025-11-22 18:36:10 +08:00
98c8a356e8 ♻️ Rebuild the activity heatmap to close #189 2025-11-22 16:19:23 +08:00
d0c16ea08f Site quick open page 2025-11-22 16:02:30 +08:00
f2c1b2a531 File management actions 2025-11-22 16:01:27 +08:00
3061f0c5a9 Site file edit 2025-11-22 15:43:35 +08:00
98f7f33c65 Site file management able to navigate folders 2025-11-22 15:24:16 +08:00
d9af5d32fd Site file management able to upload site 2025-11-22 14:59:44 +08:00
f2031697ec 🐛 Fix the site refresh didn't wrok 2025-11-22 14:44:41 +08:00
9b85b7573c 💄 Optimize publication site screen 2025-11-22 14:39:03 +08:00
4fb739b33b 💄 Desktop optimization for the site dashboard 2025-11-21 00:40:45 +08:00
c03ba3bc3a ♻️ Breakdown of the site detail page 2025-11-21 00:34:39 +08:00
100 changed files with 4780 additions and 2756 deletions

View File

@@ -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",
@@ -1340,5 +1342,137 @@
"orCreateWith": "Or\ncreate with",
"unindexedFiles": "Unindexed files",
"folder": "Folder",
"clearCompleted": "Clear Completed"
}
"clearCompleted": "Clear Completed",
"contentCantEmpty": "Content cannot be empty",
"features": "Features",
"unnamed": "Unnamed",
"fundEnvelopeLoadFailed": "Failed to load fund envelope",
"fundEnvelope": "Fund Envelope",
"fundEnvelopeRemaining": "Remaining: {} {}",
"fundEnvelopeSplit": "Split: {}",
"fundEnvelopeSplitEvenly": "Evenly",
"fundEnvelopeSplitRandomly": "Randomly",
"fundEnvelopeClaimSuccess": "Fund claimed successfully!",
"fundEnvelopeStatusCreated": "Created",
"fundEnvelopeStatusPartial": "Partially Claimed",
"fundEnvelopeStatusCompleted": "Fully Claimed",
"fundEnvelopeStatusExpired": "Expired",
"fundEnvelopeStatusUnknown": "Unknown",
"fundEnvelopeRecipients": "Recipients ({}/{} claimed)",
"fundEnvelopeExpiredDaysAgo": {
"one": "Expired {} day ago",
"other": "Expired {} days ago"
},
"fundEnvelopeExpiresSoon": "Expires soon",
"fundEnvelopeExpiresInHours": {
"one": "Expires in {} hour",
"other": "Expires in {} hours"
},
"fundEnvelopeExpiresInDays": {
"one": "Expires in {} day",
"other": "Expires in {} days"
},
"fundEnvelopeRemainingWithSplits": "{} {} / {} splits",
"fundEnvelopeUnknownUser": "Unknown User",
"deleteSite": "Delete Site",
"deleteSiteConfirm": "Are you sure you want to delete this site?",
"siteDeletedSuccess": "Site deleted successfully",
"siteSlug": "Slug",
"siteSlugHint": "my-site",
"siteSlugRequired": "Please enter a slug",
"siteSlugInvalid": "Slug can only contain lowercase letters, numbers, and dashes",
"siteName": "Site Name",
"siteNameHint": "My Publication Site",
"siteNameRequired": "Please enter a site name",
"siteMode": "Mode",
"siteModeFullyManaged": "Fully Managed",
"siteModeSelfManaged": "Self-Managed",
"editPublicationSite": "Edit Publication Site",
"deletePublicationSite": "Delete Publication Site",
"publicationSiteSavedSuccess": "Publication site saved successfully",
"publicationSiteDeleteConfirm": "Are you sure you want to delete this publication site? This action cannot be undone.",
"publicationSiteDeletedSuccess": "Publication site deleted successfully",
"newPublicationSite": "New Publication Site",
"siteDetails": "Site Details",
"siteInformation": "Site Information",
"siteDomain": "Domain",
"siteCreated": "Created",
"siteUpdated": "Updated",
"failedToLoadSite": "Failed to load site",
"sitePages": "Pages",
"noPagesYet": "No pages yet",
"createFirstPage": "Create your first page to get started",
"failedToLoadPages": "Failed to load pages",
"fileManagement": "File Management",
"siteFiles": "Files",
"siteFolder": "Folder",
"siteRoot": "Root",
"noFilesUploadedYet": "No files uploaded yet",
"uploadFirstFile": "Upload your first file to get started",
"failedToLoadFiles": "Failed to load files",
"noFilesFoundInFolder": "No files found in the selected folder",
"fileActions": "File Actions",
"purgeFiles": "Purge Files",
"purgeFilesDescription": "Remove all uploaded files from the site",
"deploySite": "Deploy Site",
"deploySiteDescription": "Upload and deploy a new version from ZIP archive",
"confirmPurge": "Confirm Purge",
"purgeFilesConfirm": "This will permanently delete all files uploaded to this site. This action cannot be undone. Are you sure you want to continue?",
"purgeAllFiles": "Purge All Files",
"allFilesPurgedSuccess": "All files purged successfully",
"failedToPurgeFiles": "Failed to purge files: {}",
"siteDeployedSuccess": "Site deployed successfully",
"failedToDeploySite": "Failed to deploy site: {}",
"createPage": "Create Page",
"editPage": "Edit Page",
"pageType": "Page Type",
"htmlPage": "HTML Page",
"redirectPage": "Redirect Page",
"pageTypeRequired": "Please select a page type",
"pagePath": "Page Path",
"pagePathHint": "/about, /contact, etc.",
"pagePathRequired": "Please enter a page path",
"pagePathInvalid": "Page path can only contain letters, numbers, hyphens, underscores, and slashes",
"pagePathMustStartWithSlash": "Page path must start with /",
"pagePathNoConsecutiveSlashes": "Page path cannot have consecutive slashes",
"pageTitle": "Page Title",
"pageTitleHint": "About Us, Contact, etc.",
"pageTitleRequired": "Please enter a page title",
"pageContentHtml": "Page Content (HTML)",
"pageContentHint": "<h1>Hello World</h1><p>This is my page content...</p>",
"pageContentRequired": "Please enter HTML content for the page",
"redirectTarget": "Redirect Target",
"redirectTargetHint": "/new-page, https://example.com, etc.",
"redirectTargetRequired": "Please enter a redirect target",
"redirectTargetInvalid": "Target must be a relative path (/) or absolute URL (http/https)",
"deletePage": "Delete Page",
"deletePageConfirm": "Are you sure you want to delete this page?",
"savePage": "Save Page",
"pageCreatedSuccess": "Page created successfully",
"pageUpdatedSuccess": "Page updated successfully",
"pageDeletedSuccess": "Page deleted successfully",
"uploadFiles": "Upload Files",
"uploadPath": "Upload Path",
"uploadPathHint": "/ (root) or /assets/images/",
"uploadPathRequired": "Please enter an upload path",
"uploadPathMustStartWithSlash": "Path must start with /",
"uploadPathNoSpaces": "Path cannot contain spaces",
"uploadPathNoConsecutiveSlashes": "Path cannot have consecutive slashes",
"percentCompleted": "{}% completed",
"filesToUpload": "{} files to upload",
"fileSizeKb": "Size: {} KB",
"uploadingEllipsis": "Uploading...",
"uploadFilesCount": {
"one": "Upload {} File",
"other": "Upload {} Files"
},
"allUploadsCompleted": "All uploads completed",
"someUploadsFailed": "Some uploads failed",
"uploadingInProgress": "Uploading in progress",
"readyToUpload": "Ready to upload",
"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",
"dropToShare": "Drop to share"
}

View File

@@ -585,10 +585,10 @@
"unknownChat": "未知聊天",
"addAdditionalMessage": "添加附加消息……",
"uploadingFiles": "上传文件中……",
"sharedSuccessfully": "分享成功",
"shareSuccess": "分享成功",
"shareToSpecificChatSuccess": "成功分享至 {}",
"wouldYouLikeToGoToChat": "是否前往该聊天?",
"sharedSuccessfully": "分享成功",
"shareSuccess": "分享成功",
"shareToSpecificChatSuccess": "成功分享至 {}",
"wouldYouLikeToGoToChat": "是否前往该聊天页面",
"no": "否",
"yes": "是",
"navigateToChat": "前往聊天",
@@ -1092,4 +1092,4 @@
"aiThought": "寻思",
"aiThoughtTitle": "让 SN 酱寻思寻思",
"thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用"
}
}

View File

@@ -140,8 +140,6 @@ PODS:
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_platform_alert (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_timezone (0.0.1):
@@ -251,14 +249,13 @@ PODS:
- nanopb/encode (3.30910.0)
- native_exif (0.0.1):
- Flutter
- objective_c (0.0.1):
- Flutter
- OrderedSet (6.0.3)
- package_info_plus (0.4.5):
- Flutter
- pasteboard (0.0.1):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- pointer_interceptor_ios (0.0.1):
- Flutter
- PromisesObjC (2.4.0)
@@ -336,7 +333,6 @@ DEPENDENCIES:
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_platform_alert (from `.symlinks/plugins/flutter_platform_alert/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
@@ -351,9 +347,9 @@ DEPENDENCIES:
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- native_exif (from `.symlinks/plugins/native_exif/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
@@ -431,8 +427,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_platform_alert:
:path: ".symlinks/plugins/flutter_platform_alert/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_timezone:
@@ -457,12 +451,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/media_kit_video/ios"
native_exif:
:path: ".symlinks/plugins/native_exif/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
pasteboard:
:path: ".symlinks/plugins/pasteboard/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios:
@@ -519,7 +513,6 @@ SPEC CHECKSUMS:
flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_platform_alert: bf3b5fcd4ac14bd637e20527e9c471633071afd3
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
flutter_udid: 92a5d31fe0526b7b6002a2318df702e12e7eb300
@@ -541,10 +534,10 @@ SPEC CHECKSUMS:
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851

View File

@@ -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,

View File

@@ -360,21 +360,41 @@ class AppDatabase extends _$AppDatabase {
}
Future<void> saveChatRooms(List<SnChatRoom> rooms) async {
await batch((batch) {
for (final room in rooms) {
batch.insert(
chatRooms,
companionFromRoom(room),
mode: InsertMode.insertOrReplace,
);
for (final member in room.members ?? []) {
await transaction(() async {
// 1. Identify rooms to remove
final remoteRoomIds = rooms.map((r) => r.id).toSet();
final currentRooms = await select(chatRooms).get();
final currentRoomIds = currentRooms.map((r) => r.id).toSet();
final idsToRemove = currentRoomIds.difference(remoteRoomIds);
if (idsToRemove.isNotEmpty) {
final idsList = idsToRemove.toList();
// Remove messages
await (delete(chatMessages)..where((t) => t.roomId.isIn(idsList))).go();
// Remove members
await (delete(chatMembers)
..where((t) => t.chatRoomId.isIn(idsList))).go();
// Remove rooms
await (delete(chatRooms)..where((t) => t.id.isIn(idsList))).go();
}
// 2. Upsert remote rooms
await batch((batch) {
for (final room in rooms) {
batch.insert(
chatMembers,
companionFromMember(member),
chatRooms,
companionFromRoom(room),
mode: InsertMode.insertOrReplace,
);
for (final member in room.members ?? []) {
batch.insert(
chatMembers,
companionFromMember(member),
mode: InsertMode.insertOrReplace,
);
}
}
}
});
});
}
@@ -429,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));
}
}

View File

@@ -212,8 +212,14 @@ class CallNotifier extends _$CallNotifier {
String? _roomId;
String? get roomId => _roomId;
Future<void> joinRoom(String roomId) async {
if (_roomId == roomId && _room != null) {
SnChatRoom? _chatRoom;
SnChatRoom? get chatRoom => _chatRoom;
Future<void> joinRoom(SnChatRoom room) async {
var roomId = room.id;
if (_roomId == roomId &&
_room != null &&
_room?.connectionState == lk.ConnectionState.connected) {
talker.info('[Call] Call skipped. Already has data');
return;
} else if (_room != null) {
@@ -223,6 +229,7 @@ class CallNotifier extends _$CallNotifier {
}
}
_roomId = roomId;
_chatRoom = room;
if (_room != null) {
await _room!.disconnect();
await _room!.dispose();
@@ -355,6 +362,7 @@ class CallNotifier extends _$CallNotifier {
sourceId: source.id,
maxFrameRate: 30.0,
captureScreenAudio: true,
useiOSBroadcastExtension: true,
),
);
await _localParticipant!.publishVideoTrack(track);

View File

@@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator
// **************************************************************************
String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281';
String _$callNotifierHash() => r'ef4e3e9c9d411cf9dce1ceb456a3b866b2c87db3';
/// See also [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: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(

View File

@@ -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].

View File

@@ -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;

View File

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

View File

@@ -35,7 +35,24 @@ Future<SnFileContent> siteFileContent(
final resp = await apiClient.get(
'/zone/sites/$siteId/files/content/$relativePath',
);
return SnFileContent.fromJson(resp.data);
final content =
resp.data is String
? resp.data
: SnFileContent.fromJson(resp.data).content;
return SnFileContent(content: content);
}
@riverpod
Future<String> siteFileContentRaw(
Ref ref, {
required String siteId,
required String relativePath,
}) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get(
'/zone/sites/$siteId/files/content/$relativePath',
);
return resp.data is String ? resp.data : resp.data['content'] as String;
}
class SiteFilesNotifier
@@ -87,7 +104,7 @@ class SiteFilesNotifier
);
// Refresh the files list
ref.invalidateSelf();
ref.invalidate(siteFilesProvider(siteId: arg.siteId, path: arg.path));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
@@ -104,7 +121,7 @@ class SiteFilesNotifier
);
// Refresh the files list
ref.invalidateSelf();
ref.invalidate(siteFilesProvider(siteId: arg.siteId, path: arg.path));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
@@ -120,7 +137,7 @@ class SiteFilesNotifier
);
// Refresh the files list
ref.invalidateSelf();
ref.invalidate(siteFilesProvider(siteId: arg.siteId, path: arg.path));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;
@@ -131,7 +148,7 @@ class SiteFilesNotifier
// For directories, we upload a dummy file first then delete it or create through upload
// Actually, according to API docs, directories are created when uploading files to them
// So we'll just invalidate to refresh the list
ref.invalidateSelf();
ref.invalidate(siteFilesProvider(siteId: arg.siteId, path: arg.path));
}
}

View File

@@ -158,7 +158,7 @@ class _SiteFilesProviderElement
String? get path => (origin as SiteFilesProvider).path;
}
String _$siteFileContentHash() => r'bb820f0fe5bdca55efb08beee97aa38d09be04a7';
String _$siteFileContentHash() => r'b594ad4f8c54555e742ece94ee001092cb2f83d1';
/// See also [siteFileContent].
@ProviderFor(siteFileContent)
@@ -300,5 +300,152 @@ class _SiteFileContentProviderElement
String get relativePath => (origin as SiteFileContentProvider).relativePath;
}
String _$siteFileContentRawHash() =>
r'd0331c30698a9f4b90fe9b79273ff5914fa46616';
/// See also [siteFileContentRaw].
@ProviderFor(siteFileContentRaw)
const siteFileContentRawProvider = SiteFileContentRawFamily();
/// See also [siteFileContentRaw].
class SiteFileContentRawFamily extends Family<AsyncValue<String>> {
/// See also [siteFileContentRaw].
const SiteFileContentRawFamily();
/// See also [siteFileContentRaw].
SiteFileContentRawProvider call({
required String siteId,
required String relativePath,
}) {
return SiteFileContentRawProvider(
siteId: siteId,
relativePath: relativePath,
);
}
@override
SiteFileContentRawProvider getProviderOverride(
covariant SiteFileContentRawProvider provider,
) {
return call(siteId: provider.siteId, relativePath: provider.relativePath);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'siteFileContentRawProvider';
}
/// See also [siteFileContentRaw].
class SiteFileContentRawProvider extends AutoDisposeFutureProvider<String> {
/// See also [siteFileContentRaw].
SiteFileContentRawProvider({
required String siteId,
required String relativePath,
}) : this._internal(
(ref) => siteFileContentRaw(
ref as SiteFileContentRawRef,
siteId: siteId,
relativePath: relativePath,
),
from: siteFileContentRawProvider,
name: r'siteFileContentRawProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$siteFileContentRawHash,
dependencies: SiteFileContentRawFamily._dependencies,
allTransitiveDependencies:
SiteFileContentRawFamily._allTransitiveDependencies,
siteId: siteId,
relativePath: relativePath,
);
SiteFileContentRawProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.siteId,
required this.relativePath,
}) : super.internal();
final String siteId;
final String relativePath;
@override
Override overrideWith(
FutureOr<String> Function(SiteFileContentRawRef provider) create,
) {
return ProviderOverride(
origin: this,
override: SiteFileContentRawProvider._internal(
(ref) => create(ref as SiteFileContentRawRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
siteId: siteId,
relativePath: relativePath,
),
);
}
@override
AutoDisposeFutureProviderElement<String> createElement() {
return _SiteFileContentRawProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SiteFileContentRawProvider &&
other.siteId == siteId &&
other.relativePath == relativePath;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, siteId.hashCode);
hash = _SystemHash.combine(hash, relativePath.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SiteFileContentRawRef on AutoDisposeFutureProviderRef<String> {
/// The parameter `siteId` of this provider.
String get siteId;
/// The parameter `relativePath` of this provider.
String get relativePath;
}
class _SiteFileContentRawProviderElement
extends AutoDisposeFutureProviderElement<String>
with SiteFileContentRawRef {
_SiteFileContentRawProviderElement(super.provider);
@override
String get siteId => (origin as SiteFileContentRawProvider).siteId;
@override
String get relativePath =>
(origin as SiteFileContentRawProvider).relativePath;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -62,7 +62,7 @@ class SitePagesNotifier
final newPage = SnPublicationPage.fromJson(resp.data);
// Refresh the pages list
ref.invalidateSelf();
ref.invalidate(sitePagesProvider(arg.pubName, arg.siteSlug));
return newPage;
} catch (error, stackTrace) {
@@ -85,7 +85,7 @@ class SitePagesNotifier
final updatedPage = SnPublicationPage.fromJson(resp.data);
// Refresh the pages list
ref.invalidateSelf();
ref.invalidate(sitePagesProvider(arg.pubName, arg.siteSlug));
return updatedPage;
} catch (error, stackTrace) {
@@ -101,7 +101,7 @@ class SitePagesNotifier
await apiClient.delete('/zone/sites/pages/$pageId');
// Refresh the pages list
ref.invalidateSelf();
ref.invalidate(sitePagesProvider(arg.pubName, arg.siteSlug));
} catch (error, stackTrace) {
state = AsyncValue.error(error, stackTrace);
rethrow;

View File

@@ -45,7 +45,7 @@ class SiteNotifier
final response =
site.id.isEmpty
? await client.post(url, data: site.toJson())
: await client.patch('${url}/${site.id}', data: site.toJson());
: await client.patch('$url/${site.id}', data: site.toJson());
state = AsyncValue.data(SnPublicationSite.fromJson(response.data));
} catch (error, stackTrace) {

View File

@@ -5,7 +5,8 @@ import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
import 'package:flutter/material.dart';
import 'package:island/widgets/alert.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
@@ -36,41 +37,65 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
} catch (error, stackTrace) {
if (!kIsWeb) {
if (error is DioException) {
FlutterPlatformAlert.showCustomAlert(
windowTitle: 'failedToLoadUserInfo'.tr(),
text: [
(error.response?.statusCode == 401
? 'failedToLoadUserInfoUnauthorized'
: 'failedToLoadUserInfoNetwork')
.tr()
.trim(),
'',
'${error.response?.statusCode ?? 'Network Error'}',
if (error.response?.headers != null) error.response?.headers,
if (error.response?.data != null)
jsonEncode(error.response?.data),
].join('\n'),
iconStyle: IconStyle.error,
neutralButtonTitle: 'retry'.tr(),
negativeButtonTitle: 'okay'.tr(),
showOverlayDialog<bool>(
builder:
(context, close) => AlertDialog(
title: Text('failedToLoadUserInfo'.tr()),
content: Text(
[
(error.response?.statusCode == 401
? 'failedToLoadUserInfoUnauthorized'
: 'failedToLoadUserInfoNetwork')
.tr()
.trim(),
'',
'${error.response?.statusCode ?? 'Network Error'}',
if (error.response?.headers != null)
error.response?.headers,
if (error.response?.data != null)
jsonEncode(error.response?.data),
].join('\n'),
),
actions: [
TextButton(
onPressed: () => close(false),
child: Text('okay'.tr()),
),
TextButton(
onPressed: () => close(true),
child: Text('retry'.tr()),
),
],
),
).then((value) {
if (value == CustomButton.neutralButton) {
if (value == true) {
fetchUser();
}
});
}
FlutterPlatformAlert.showCustomAlert(
windowTitle: 'failedToLoadUserInfo'.tr(),
text:
[
'failedToLoadUserInfoNetwork'.tr(),
error.toString(),
].join('\n\n').trim(),
iconStyle: IconStyle.error,
neutralButtonTitle: 'retry'.tr(),
negativeButtonTitle: 'okay'.tr(),
showOverlayDialog<bool>(
builder:
(context, close) => AlertDialog(
title: Text('failedToLoadUserInfo'.tr()),
content: Text(
[
'failedToLoadUserInfoNetwork'.tr(),
error.toString(),
].join('\n\n').trim(),
),
actions: [
TextButton(
onPressed: () => close(false),
child: Text('okay'.tr()),
),
TextButton(
onPressed: () => close(true),
child: Text('retry'.tr()),
),
],
),
).then((value) {
if (value == CustomButton.neutralButton) {
if (value == true) {
fetchUser();
}
});

View File

@@ -32,7 +32,6 @@ import 'package:island/screens/account/me/account_settings.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:island/screens/chat/room.dart';
import 'package:island/screens/chat/room_detail.dart';
import 'package:island/screens/chat/call.dart';
import 'package:island/screens/chat/search_messages.dart';
import 'package:island/screens/thought/think.dart';
import 'package:island/screens/creators/hub.dart';
@@ -119,14 +118,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ArticleEditScreen(id: id);
},
),
GoRoute(
name: 'chatCall',
path: '/chat/:id/call',
builder: (context, state) {
final id = state.pathParameters['id']!;
return CallScreen(roomId: id);
},
),
GoRoute(
name: 'logs',
path: '/logs',

View File

@@ -384,9 +384,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
icon: const Icon(Symbols.content_copy, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('copiedToClipboard'.tr())),
);
showSnackBar('copiedToClipboard'.tr());
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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=", "");

View File

@@ -3,30 +3,31 @@ import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/chat/call.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_content.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/alert.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class CallScreen extends HookConsumerWidget {
final String roomId;
const CallScreen({super.key, required this.roomId});
final SnChatRoom room;
const CallScreen({super.key, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ongoingCall = ref.watch(ongoingCallProvider(roomId));
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.watch(callNotifierProvider.notifier);
useEffect(() {
talker.info('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) {
callNotifier.joinRoom(room).catchError((_) {
showConfirmAlert(
'Seems there already has a call connected, do you want override it?',
'Call already connected',
@@ -35,7 +36,7 @@ class CallScreen extends HookConsumerWidget {
talker.info('[Call] Joining the call... with overrides');
callNotifier.disconnect();
callNotifier.dispose();
callNotifier.joinRoom(roomId);
callNotifier.joinRoom(room);
});
});
return null;
@@ -110,7 +111,7 @@ class CallScreen extends HookConsumerWidget {
onPressed: () {
callNotifier.disconnect();
callNotifier.dispose();
callNotifier.joinRoom(roomId);
callNotifier.joinRoom(room);
},
child: Text('retry').tr(),
),
@@ -120,72 +121,7 @@ class CallScreen extends HookConsumerWidget {
)
: Column(
children: [
Expanded(
child: Builder(
builder: (context) {
if (!callState.isConnected) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (callNotifier.participants.isEmpty) {
return const Center(
child: Text('No participants in call'),
);
}
final participants = callNotifier.participants;
if (allAudioOnly) {
// Audio-only: show avatars in a compact row
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final live in participants)
SpeakingRippleAvatar(
live: live,
size: 72,
).padding(horizontal: 4),
],
),
),
);
}
// Stage view: show main speaker(s) large, others in row
final mainSpeakers =
participants
.where(
(p) => p
.remoteParticipant
.trackPublications
.values
.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO,
),
)
.toList();
if (mainSpeakers.isEmpty && participants.isNotEmpty) {
mainSpeakers.add(participants.first);
}
return Column(
children: [
for (final speaker in mainSpeakers)
Expanded(
child: CallParticipantTile(live: speaker),
),
],
);
},
),
),
Expanded(child: CallContent()),
CallControlsBar(),
Gap(MediaQuery.of(context).padding.bottom + 16),
],

View File

@@ -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(

View File

@@ -39,6 +39,7 @@ import "package:island/widgets/chat/chat_input.dart";
import "package:island/widgets/chat/chat_link_attachments.dart";
import "package:island/widgets/chat/public_room_preview.dart";
import "package:island/screens/thought/think_sheet.dart";
import "package:island/screens/chat/widgets/message_item_wrapper.dart";
class ChatRoomScreen extends HookConsumerWidget {
final String id;
@@ -178,6 +179,38 @@ class ChatRoomScreen extends HookConsumerWidget {
final isSelectionMode = useState<bool>(false);
final selectedMessages = useState<Set<String>>({});
final roomOpenTime = useMemoized(() => DateTime.now());
final onMessageAction = useCallback(
(String action, LocalChatMessage message) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(message.id);
case MessageItemAction.edit:
messageEditingTo.value = message.toRemoteMessage();
messageController.text = messageEditingTo.value?.content ?? '';
attachments.value =
messageEditingTo.value!.attachments
.map((e) => UniversalFile.fromAttachment(e))
.toList();
case MessageItemAction.forward:
messageForwardingTo.value = message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value = message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id);
}
},
[
messagesNotifier,
messageEditingTo,
messageController,
attachments,
messageForwardingTo,
messageReplyingTo,
],
);
var isLoading = false;
var isScrollingToMessage = false; // Flag to prevent scroll conflicts
@@ -627,7 +660,6 @@ class ChatRoomScreen extends HookConsumerWidget {
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(
top: 16,
bottom: MediaQuery.of(context).padding.bottom + 8 + inputHeight.value,
),
child: SuperListView.builder(
@@ -659,138 +691,30 @@ class ChatRoomScreen extends HookConsumerWidget {
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}');
final messageWidget = chatIdentity.when(
skipError: true,
data:
(identity) => GestureDetector(
onLongPress: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3)
: null,
child: Stack(
children: [
MessageItem(
key: settings.disableAnimation ? key : null,
message: message,
isCurrentUser: identity?.id == message.senderId,
onAction:
isSelectionMode.value
? null
: (action) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(
message.id,
);
case MessageItemAction.edit:
messageEditingTo.value =
message.toRemoteMessage();
messageController.text =
messageEditingTo.value?.content ??
'';
attachments.value =
messageEditingTo.value!.attachments
.map(
(e) =>
UniversalFile.fromAttachment(
e,
),
)
.toList();
case MessageItemAction.forward:
messageForwardingTo.value =
message.toRemoteMessage();
case MessageItemAction.reply:
messageReplyingTo.value =
message.toRemoteMessage();
case MessageItemAction.resend:
messagesNotifier.retryMessage(
message.id,
);
}
},
onJump:
(messageId) => scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
),
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode.value,
isSelected: selectedMessages.value.contains(
message.id,
),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode.value) toggleSelectionMode();
},
),
if (selectedMessages.value.contains(message.id))
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
),
return MessageItemWrapper(
key: key,
message: message,
index: index,
isLastInGroup: isLastInGroup,
isSelectionMode: isSelectionMode.value,
selectedMessages: selectedMessages.value,
chatIdentity: chatIdentity,
toggleSelectionMode: toggleSelectionMode,
toggleMessageSelection: toggleMessageSelection,
onMessageAction: onMessageAction,
onJump:
(messageId) => scrollToMessage(
messageId: messageId,
messageList: messageList,
messagesNotifier: messagesNotifier,
listController: listController,
scrollController: scrollController,
ref: ref,
),
loading:
() => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
attachmentProgress: attachmentProgress.value,
disableAnimation: settings.disableAnimation,
roomOpenTime: roomOpenTime,
);
return settings.disableAnimation
? messageWidget
: TweenAnimationBuilder<double>(
key: key,
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 400 + (index % 5) * 50),
curve: Curves.easeOutCubic,
builder:
(context, animationValue, child) => Transform.translate(
offset: Offset(0, 20 * (1 - animationValue)),
child: Opacity(opacity: animationValue, child: child),
),
child: messageWidget,
);
},
),
);
@@ -814,7 +738,11 @@ class ChatRoomScreen extends HookConsumerWidget {
),
),
actions: [
AudioCallButton(roomId: id),
chatRoom.when(
data: (data) => AudioCallButton(room: data!),
error: (err, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () async {
@@ -915,7 +843,14 @@ class ChatRoomScreen extends HookConsumerWidget {
left: 0,
right: 0,
top: 0,
child: CallOverlayBar().padding(horizontal: 8, top: 12),
child: chatRoom.when(
data:
(data) => CallOverlayBar(
room: data!,
).padding(horizontal: 8, top: 12),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
),
if (isSyncing)
Positioned(

View File

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

View File

@@ -0,0 +1,169 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/database/message.dart';
import 'package:island/models/chat.dart';
import 'package:island/widgets/chat/message_item.dart';
// Provider to track animated messages to prevent replay
final animatedMessagesProvider = StateProvider<Set<String>>((ref) => {});
class MessageItemWrapper extends HookConsumerWidget {
final LocalChatMessage message;
final int index;
final bool isLastInGroup;
final bool isSelectionMode;
final Set<String> selectedMessages;
final AsyncValue<SnChatMember?> chatIdentity;
final VoidCallback toggleSelectionMode;
final Function(String) toggleMessageSelection;
final Function(String, LocalChatMessage) onMessageAction;
final Function(String) onJump;
final Map<String, Map<int, double?>> attachmentProgress;
final bool disableAnimation;
final DateTime roomOpenTime;
const MessageItemWrapper({
super.key,
required this.message,
required this.index,
required this.isLastInGroup,
required this.isSelectionMode,
required this.selectedMessages,
required this.chatIdentity,
required this.toggleSelectionMode,
required this.toggleMessageSelection,
required this.onMessageAction,
required this.onJump,
required this.attachmentProgress,
required this.disableAnimation,
required this.roomOpenTime,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Animation logic
final animatedMessages = ref.watch(animatedMessagesProvider);
final isNewMessage = message.createdAt.isAfter(roomOpenTime);
final hasAnimated = animatedMessages.contains(message.id);
// Only animate if:
// 1. Animation is enabled
// 2. Message is new (created after room open)
// 3. Has not animated yet
final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated;
final child = chatIdentity.when(
skipError: true,
data: (identity) => _buildContent(context, identity),
loading: () => _buildLoading(),
error: (_, _) => const SizedBox.shrink(),
);
if (!shouldAnimate) {
return child;
}
return TweenAnimationBuilder<double>(
key: ValueKey('anim-${message.id}'), // Ensure unique key for animation
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 400 + (index % 5) * 50),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: Opacity(opacity: value, child: child),
);
},
onEnd: () {
// Mark as animated
WidgetsBinding.instance.addPostFrameCallback((_) {
ref
.read(animatedMessagesProvider.notifier)
.update((state) => {...state, message.id});
});
},
child: child,
);
}
Widget _buildContent(BuildContext context, SnChatMember? identity) {
final isSelected = selectedMessages.contains(message.id);
final isCurrentUser = identity?.id == message.senderId;
return GestureDetector(
onLongPress: () {
if (!isSelectionMode) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
isSelected
? Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3)
: null,
child: Stack(
children: [
MessageItem(
// If animation is disabled, we might want to pass a key to maintain state?
// But here we are inside the wrapper.
key: ValueKey('item-${message.id}'),
message: message,
isCurrentUser: isCurrentUser,
onAction:
isSelectionMode
? null
: (action) => onMessageAction(action, message),
onJump: onJump,
progress: attachmentProgress[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode) toggleSelectionMode();
},
),
if (isSelected)
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
),
);
}
Widget _buildLoading() {
return MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
);
}
}

View File

@@ -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(
@@ -846,7 +848,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.post(
'/sphere/publishers/$publisherUname/invites',
'/sphere/publishers/invites/$publisherUname',
data: {'related_user_id': result.id, 'role': 0},
);
// Refresh both providers
@@ -1134,7 +1136,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
try {
final client = ref.read(apiClientProvider);
await client.post(
'/publishers/invites/${invite.publisher!.name}/accept',
'/sphere/publishers/invites/${invite.publisher!.name}/accept',
);
ref.invalidate(publisherInvitesProvider);
ref.invalidate(publishersManagedProvider);
@@ -1147,7 +1149,7 @@ class _PublisherInviteSheet extends HookConsumerWidget {
try {
final client = ref.read(apiClientProvider);
await client.post(
'/publishers/invites/${invite.publisher!.name}/decline',
'/sphere/publishers/invites/${invite.publisher!.name}/decline',
);
ref.invalidate(publisherInvitesProvider);
} catch (err) {

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/poll/poll_feedback.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -234,19 +235,9 @@ class _CreatorPollItem extends HookConsumerWidget {
'/sphere/polls/${pollWithStats.id}',
);
ref.invalidate(pollListNotifierProvider(pubName));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Poll deleted successfully'),
),
);
}
showSnackBar('Poll deleted successfully');
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete poll')),
);
}
showErrorAlert(e);
}
}
},

File diff suppressed because it is too large Load Diff

View File

@@ -31,20 +31,20 @@ class SiteForm extends HookConsumerWidget {
children: [
TextFormField(
controller: slugController,
decoration: const InputDecoration(
labelText: 'Slug',
hintText: 'my-site',
decoration: InputDecoration(
labelText: 'siteSlug'.tr(),
hintText: 'siteSlugHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a slug';
return 'siteSlugRequired'.tr();
}
final slugRegex = RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$');
if (!slugRegex.hasMatch(value)) {
return 'Slug can only contain lowercase letters, numbers, and dashes';
return 'siteSlugInvalid'.tr();
}
return null;
},
@@ -53,16 +53,16 @@ class SiteForm extends HookConsumerWidget {
const SizedBox(height: 16),
TextFormField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Site Name',
hintText: 'My Publication Site',
decoration: InputDecoration(
labelText: 'siteName'.tr(),
hintText: 'siteNameHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a site name';
return 'siteNameRequired'.tr();
}
return null;
},
@@ -71,8 +71,8 @@ class SiteForm extends HookConsumerWidget {
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
decoration: InputDecoration(
labelText: 'description'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
@@ -84,15 +84,18 @@ class SiteForm extends HookConsumerWidget {
const SizedBox(height: 16),
DropdownButtonFormField<int>(
value: modeController.value,
decoration: const InputDecoration(
labelText: 'Mode',
decoration: InputDecoration(
labelText: 'siteMode'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
items: const [
DropdownMenuItem(value: 0, child: Text('Fully Managed')),
DropdownMenuItem(value: 1, child: Text('Self-Managed')),
items: [
DropdownMenuItem(
value: 0,
child: Text('siteModeFullyManaged'.tr()),
),
DropdownMenuItem(value: 1, child: Text('siteModeSelfManaged'.tr())),
],
onChanged: (value) {
if (value != null) {
@@ -104,7 +107,7 @@ class SiteForm extends HookConsumerWidget {
).padding(all: 20);
return SheetScaffold(
titleText: 'Edit Publication Site',
titleText: 'editPublicationSite'.tr(),
child: Builder(
builder:
(context) => SingleChildScrollView(
@@ -116,7 +119,7 @@ class SiteForm extends HookConsumerWidget {
TextButton.icon(
onPressed: deleteSite,
icon: const Icon(Symbols.delete_forever),
label: const Text('Delete Publication Site'),
label: Text('deletePublicationSite'.tr()),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
@@ -171,7 +174,7 @@ class SiteForm extends HookConsumerWidget {
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
showSnackBar('Publication site saved successfully');
showSnackBar('publicationSiteSavedSuccess'.tr());
Navigator.pop(context);
}
} catch (e) {
@@ -185,8 +188,9 @@ class SiteForm extends HookConsumerWidget {
if (siteSlug == null) return; // Shouldn't happen for editing
final confirmed = await showConfirmAlert(
'Are you sure you want to delete this publication site? This action cannot be undone.',
'Delete Publication Site',
'publicationSiteDeleteConfirm'.tr(),
'deletePublicationSite'.tr(),
isDanger: true,
);
if (confirmed != true) return;
@@ -199,7 +203,7 @@ class SiteForm extends HookConsumerWidget {
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
showSnackBar('Publication site deleted successfully');
showSnackBar('publicationSiteDeletedSuccess'.tr());
Navigator.pop(context);
}
} catch (e) {
@@ -243,13 +247,13 @@ class SiteForm extends HookConsumerWidget {
editingSiteSlug,
),
loading:
() => const SheetScaffold(
titleText: 'Edit Publication Site',
() => SheetScaffold(
titleText: 'editPublicationSite'.tr(),
child: Center(child: CircularProgressIndicator()),
),
error:
(error, _) => SheetScaffold(
titleText: 'Edit Publication Site',
titleText: 'editPublicationSite'.tr(),
child: ResponseErrorWidget(
error: error.toString(),
onRetry: () {
@@ -327,9 +331,12 @@ class SiteForm extends HookConsumerWidget {
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
items: const [
DropdownMenuItem(value: 0, child: Text('Fully Managed')),
DropdownMenuItem(value: 1, child: Text('Self-Managed')),
items: [
DropdownMenuItem(
value: 0,
child: Text('siteModeFullyManaged'.tr()),
),
DropdownMenuItem(value: 1, child: Text('siteModeSelfManaged'.tr())),
],
onChanged: (value) {
if (value != null) {
@@ -348,7 +355,9 @@ class SiteForm extends HookConsumerWidget {
return SheetScaffold(
titleText:
siteSlug == null ? 'New Publication Site' : 'Edit Publication Site',
siteSlug == null
? 'newPublicationSite'.tr()
: 'editPublicationSite'.tr(),
child: SingleChildScrollView(
child: Column(
children: [
@@ -359,7 +368,7 @@ class SiteForm extends HookConsumerWidget {
TextButton.icon(
onPressed: isLoading.value ? null : deleteSite,
icon: const Icon(Symbols.delete_forever),
label: const Text('Delete Publication Site'),
label: Text('deletePublicationSite'.tr()),
style: TextButton.styleFrom(foregroundColor: Colors.red),
).alignment(Alignment.centerRight),
const SizedBox(height: 16),

View File

@@ -2,10 +2,12 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/sites/site_edit.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -72,7 +74,7 @@ class CreatorSiteListScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text('Publication Sites')),
appBar: AppBar(title: Text('publicationSites'.tr())),
floatingActionButton: FloatingActionButton(
onPressed: () => _createSite(context),
child: Icon(Icons.add),
@@ -81,6 +83,7 @@ class CreatorSiteListScreen extends HookConsumerWidget {
onRefresh: () => ref.refresh(siteListNotifierProvider(pubName).future),
child: CustomScrollView(
slivers: [
const SliverGap(8),
PagingHelperSliverView(
provider: siteListNotifierProvider(pubName),
futureRefreshable: siteListNotifierProvider(pubName).future,
@@ -115,110 +118,10 @@ class _CreatorSiteItem extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
clipBehavior: Clip.antiAlias,
child: ListTile(
title: Text(site.name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (site.description != null && site.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
site.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Slug: ${site.slug} · Pages: ${site.pages.length}',
style: theme.textTheme.bodySmall,
),
),
],
),
trailing: PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) =>
SiteForm(pubName: pubName, siteSlug: site.slug),
);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('Delete Site'),
content: Text(
'Are you sure you want to delete this site?',
),
actions: [
TextButton(
onPressed:
() => Navigator.of(context).pop(false),
child: Text('Cancel'),
),
TextButton(
onPressed:
() => Navigator.of(context).pop(true),
child: Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/${site.id}');
ref.invalidate(siteListNotifierProvider(pubName));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Site deleted successfully'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete site')),
);
}
}
}
},
),
],
),
child: InkWell(
onTap: () {
// Navigate to site detail screen
context.pushNamed(
@@ -226,6 +129,114 @@ class _CreatorSiteItem extends HookConsumerWidget {
pathParameters: {'name': pubName, 'siteSlug': site.slug},
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
spacing: 2,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.globe,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const Gap(6),
Text(site.name).bold(),
],
),
if (site.description != null &&
site.description!.isNotEmpty)
Text(
site.description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Divider(height: 8),
Text(
'${site.slug}.solian.page',
style: GoogleFonts.robotoMono(fontSize: 11),
).opacity(0.8),
],
),
),
PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SiteForm(
pubName: pubName,
siteSlug: site.slug,
),
);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('deleteSite'.tr()),
content: Text('deleteSiteConfirm'.tr()),
actions: [
TextButton(
onPressed:
() =>
Navigator.of(context).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed:
() => Navigator.of(context).pop(true),
child: Text('delete'.tr()),
),
],
),
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete(
'/zone/sites/$pubName/${site.slug}',
);
ref.invalidate(siteListNotifierProvider(pubName));
showSnackBar('siteDeletedSuccess'.tr());
} catch (e) {
showErrorAlert(e);
}
}
},
),
],
),
],
),
),
),
);
}

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -631,6 +631,7 @@ class _ProjectListTile extends HookConsumerWidget {
showConfirmAlert(
'deleteProjectHint'.tr(),
'deleteProject'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) {
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: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,
),
),
],
),
),
),
),
],
),
);
}
@@ -545,6 +599,7 @@ class ExploreScreen extends HookConsumerWidget {
SliverToBoxAdapter(
child: FriendsOverviewWidget(
padding: const EdgeInsets.only(bottom: 8),
hideWhenEmpty: true,
),
),
if (notificationCount.value != null &&
@@ -581,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),

View File

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

View File

@@ -534,7 +534,7 @@ class _LotteryPurchaseSheetState extends State<LotteryPurchaseSheet> {
),
const Gap(4),
Text(
'The last selected number will be your special number.',
'lotteryLastNumberSpecial'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,
@@ -738,11 +738,11 @@ class _LotteryPurchaseSheetState extends State<LotteryPurchaseSheet> {
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a multiplier';
return 'lotteryMultiplierRequired'.tr();
}
final parsed = int.tryParse(value);
if (parsed == null || parsed < 1 || parsed > 10) {
return 'Multiplier must be between 1 and 10';
return 'lotteryMultiplierRange'.tr();
}
return null;
},

View File

@@ -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:

View File

@@ -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>>(

View File

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

View File

@@ -427,6 +427,7 @@ class _RealmActionMenu extends HookConsumerWidget {
showConfirmAlert(
'deleteRealmHint'.tr(),
'deleteRealm'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) {
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: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(),

View File

@@ -82,12 +82,32 @@ class _ParsedVersion implements Comparable<_ParsedVersion> {
return _ParsedVersion(major, minor, patch, build);
}
/// Normalize Android build numbers by removing architecture-based offsets
/// Android adds 1000 for x86, 2000 for ARMv7, 4000 for ARMv8
int get normalizedBuild {
// Check if build number has an architecture offset
// We detect this by checking if the build % 1000 is the base build
if (build >= 4000) {
// Likely ARMv8 (arm64-v8a) with +4000 offset
return build % 4000;
} else if (build >= 2000) {
// Likely ARMv7 (armeabi-v7a) with +2000 offset
return build % 2000;
} else if (build >= 1000) {
// Likely x86/x86_64 with +1000 offset
return build % 1000;
}
// No offset, return as-is
return build;
}
@override
int compareTo(_ParsedVersion other) {
if (major != other.major) return major.compareTo(other.major);
if (minor != other.minor) return minor.compareTo(other.minor);
if (patch != other.patch) return patch.compareTo(other.patch);
return build.compareTo(other.build);
// Use normalized build numbers for comparison to handle Android arch offsets
return normalizedBuild.compareTo(other.normalizedBuild);
}
@override
@@ -244,13 +264,14 @@ class UpdateService {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => _WindowsUpdateDialog(
updateUrl: url,
onComplete: () {
// Close the update sheet
Navigator.of(context).pop();
},
),
builder:
(context) => _WindowsUpdateDialog(
updateUrl: url,
onComplete: () {
// Close the update sheet
Navigator.of(context).pop();
},
),
);
}
@@ -321,7 +342,9 @@ class _WindowsUpdateDialog extends StatefulWidget {
class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null);
final ValueNotifier<String> messageNotifier = ValueNotifier<String>('Downloading installer...');
final ValueNotifier<String> messageNotifier = ValueNotifier<String>(
'Downloading installer...',
);
@override
void initState() {
@@ -392,16 +415,17 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
Navigator.of(context).pop();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Update Failed'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
builder:
(context) => AlertDialog(
title: const Text('Update Failed'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
],
),
);
}
@@ -458,7 +482,9 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
);
if (response.statusCode == 200) {
talker.info('[Update] Windows installer downloaded successfully to: $filePath');
talker.info(
'[Update] Windows installer downloaded successfully to: $filePath',
);
return filePath;
} else {
talker.error(
@@ -500,7 +526,9 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
}
}
talker.info('[Update] Windows installer extracted successfully to: $extractDir');
talker.info(
'[Update] Windows installer extracted successfully to: $extractDir',
);
return extractDir;
} catch (e) {
talker.error('[Update] Error extracting Windows installer: $e');
@@ -514,10 +542,11 @@ class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
talker.info('[Update] Running Windows installer from: $extractDir');
final dir = Directory(extractDir);
final exeFiles = dir
.listSync()
.where((f) => f is File && f.path.endsWith('.exe'))
.toList();
final exeFiles =
dir
.listSync()
.where((f) => f is File && f.path.endsWith('.exe'))
.toList();
if (exeFiles.isEmpty) {
talker.info('[Update] No .exe file found in extracted directory');

View File

@@ -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 {

View File

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

View File

@@ -1,11 +1,24 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_heatmap/fl_heatmap.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/heatmap.dart';
import '../services/responsive.dart';
import 'package:island/services/responsive.dart';
/// Custom data class for selected heatmap item
class SelectedHeatmapItem {
final double value;
final String unit;
final String dateString;
final String dayLabel;
SelectedHeatmapItem({
required this.value,
required this.unit,
required this.dateString,
required this.dayLabel,
});
}
/// A reusable heatmap widget for displaying activity data in GitHub-style layout.
/// Shows exactly 365 days (wide screen) or 90 days (non-wide screen) of data ending at the current date.
@@ -21,7 +34,7 @@ class ActivityHeatmapWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedItem = useState<HeatmapItem?>(null);
final selectedItem = useState<SelectedHeatmapItem?>(null);
final now = DateTime.now();
@@ -101,48 +114,18 @@ class ActivityHeatmapWidget extends HookConsumerWidget {
}
}
final heatmapData = HeatmapData(
rows: [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
], // Days of week vertically
columns:
weeks
.map(
(w) =>
'${w.year}-${w.month.toString().padLeft(2, '0')}-${w.day.toString().padLeft(2, '0')}',
)
.toList(), // Weeks horizontally
items: [
for (int day = 0; day < 7; day++) // For each day of week (Mon-Sun)
for (final week in weeks) // For each week
HeatmapItem(
value: dataMap[week.add(Duration(days: day))] ?? 0.0,
unit: heatmap.unit,
xAxisLabel:
'${week.year}-${week.month.toString().padLeft(2, '0')}-${week.day.toString().padLeft(2, '0')}',
yAxisLabel:
day == 0
? 'Mon'
: day == 1
? 'Tue'
: day == 2
? 'Wed'
: day == 3
? 'Thu'
: day == 4
? 'Fri'
: day == 5
? 'Sat'
: 'Sun',
),
],
);
// Find maximum value for color scaling
final maxValue =
dataMap.values.isNotEmpty
? dataMap.values.reduce((a, b) => a > b ? a : b)
: 1.0;
// Helper function to get color based on activity level
Color getActivityColor(double value) {
if (value == 0) return Colors.grey.withOpacity(0.1);
final intensity = value / maxValue;
return Colors.green.withOpacity(0.2 + (intensity * 0.8));
}
return Card(
margin: EdgeInsets.zero,
@@ -151,39 +134,103 @@ class ActivityHeatmapWidget extends HookConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'activityHeatmap',
style: Theme.of(context).textTheme.titleMedium,
).tr(),
const Gap(8),
// Month labels row
// Month labels row - aligned with month start positions
Row(
children: [
const SizedBox(width: 30), // Space for day labels
...monthLabels.asMap().entries.map((entry) {
final month = entry.value;
...List.generate(weeks.length, (weekIndex) {
// Check if this week is the start of a month
final monthIndex = monthPositions.indexOf(weekIndex);
final monthText =
monthIndex != -1 ? monthLabels[monthIndex] : null;
return Expanded(
child: Container(
alignment: Alignment.center,
child: Text(
month,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
);
return monthText != null
? Expanded(
child: Text(
monthText,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
)
: SizedBox.shrink();
}),
],
),
const Gap(4),
Heatmap(
heatmapData: heatmapData,
rowsVisible: 7,
showXAxisLabels: false,
onItemSelectedListener: (item) {
selectedItem.value = item;
},
// Custom heatmap grid
Column(
children: List.generate(7, (dayIndex) {
final dayLabels = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
final dayLabel = dayLabels[dayIndex];
return Row(
children: [
// Day label
SizedBox(
width: 30,
child: Text(
dayLabel,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
// Activity squares for each week - evenly distributed
Expanded(
child: Row(
children: List.generate(weeks.length, (weekIndex) {
final week = weeks[weekIndex];
final date = week.add(Duration(days: dayIndex));
final value = dataMap[date] ?? 0.0;
final dateString =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
return Expanded(
child: GestureDetector(
onTap: () {
selectedItem.value = SelectedHeatmapItem(
value: value,
unit: heatmap.unit,
dateString: dateString,
dayLabel: dayLabel,
);
},
child: Container(
height: 12,
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: getActivityColor(value),
borderRadius: BorderRadius.circular(2),
border:
selectedItem.value != null &&
selectedItem.value!.dateString ==
dateString &&
selectedItem.value!.dayLabel ==
dayLabel
? Border.all(
color: Colors.blue,
width: 1,
)
: null,
),
),
),
);
}),
),
),
],
);
}),
),
const Gap(8),
// Legend
@@ -203,9 +250,7 @@ class ActivityHeatmapWidget extends HookConsumerWidget {
style: Theme.of(context).textTheme.bodySmall,
),
TextSpan(
text: _formatDate(
selectedItem.value!.xAxisLabel ?? '',
),
text: _formatDate(selectedItem.value!.dateString),
style: Theme.of(context).textTheme.bodySmall,
),
],

View File

@@ -1,13 +1,15 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
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';
export 'content/alert.native.dart'
if (dart.library.html) 'content/alert.web.dart';
void showSnackBar(String message, {SnackBarAction? action}) {
final context = globalOverlay.currentState!.context;
final screenWidth = MediaQuery.of(context).size.width;
@@ -29,43 +31,60 @@ void showSnackBar(String message, {SnackBarAction? action}) {
),
),
),
curve: Curves.easeInOut,
snackBarPosition: SnackBarPosition.bottom,
);
}
void clearSnackBar(BuildContext context) {
ScaffoldMessenger.of(context).clearSnackBars();
}
OverlayEntry? _loadingOverlay;
GlobalKey<_FadeOverlayState> _loadingOverlayKey = GlobalKey();
class _FadeOverlay extends StatefulWidget {
const _FadeOverlay({super.key, required this.child});
final Widget child;
const _FadeOverlay({
super.key,
this.child,
this.builder,
this.duration = const Duration(milliseconds: 200),
this.curve = Curves.linear,
}) : assert(child != null || builder != null);
final Widget? child;
final Widget Function(BuildContext, Animation<double>)? builder;
final Duration duration;
final Curve curve;
@override
State<_FadeOverlay> createState() => _FadeOverlayState();
}
class _FadeOverlayState extends State<_FadeOverlay> {
bool _visible = false;
class _FadeOverlayState extends State<_FadeOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() => _visible = true);
});
_controller = AnimationController(vsync: this, duration: widget.duration);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> animateOut() async {
await _controller.reverse();
}
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: widget.child,
);
final animation = CurvedAnimation(parent: _controller, curve: widget.curve);
if (widget.builder != null) {
return widget.builder!(context, animation);
}
return FadeTransition(opacity: animation, child: widget.child);
}
}
@@ -109,10 +128,250 @@ void hideLoadingModal(BuildContext context) async {
final state = entry.mounted ? _loadingOverlayKey.currentState : null;
if (state != null) {
// ignore: invalid_use_of_protected_member
state.setState(() => state._visible = false);
await Future.delayed(const Duration(milliseconds: 200));
await state.animateOut();
}
entry.remove();
}
String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
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,
bool barrierDismissible = true,
}) {
final completer = Completer<T?>();
final key = GlobalKey<_FadeOverlayState>();
late OverlayEntry entry;
void close(T? result) async {
if (completer.isCompleted) return;
final state = key.currentState;
if (state != null) {
await state.animateOut();
}
entry.remove();
_activeOverlayDialogs.remove(close);
completer.complete(result);
}
entry = OverlayEntry(
builder:
(context) => _FadeOverlay(
key: key,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
builder: (context, animation) {
return Stack(
children: [
Positioned.fill(
child: FadeTransition(
opacity: animation,
child: GestureDetector(
onTap: barrierDismissible ? () => close(null) : null,
behavior: HitTestBehavior.opaque,
child: const ColoredBox(color: Colors.black54),
),
),
),
Center(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.05),
end: Offset.zero,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: builder(context, close),
),
),
),
],
);
},
),
);
_activeOverlayDialogs.add(() => close(null));
globalOverlay.currentState!.insert(entry);
return completer.future;
}
// 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);
}
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),
Exception _ => err.toString(),
_ => err.toString(),
};
showOverlayDialog<void>(
builder:
(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, {IconData? icon}) {
showOverlayDialog<void>(
builder:
(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, {
IconData? icon,
bool isDanger = false,
}) async {
final result = await showOverlayDialog<bool>(
builder:
(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),
],
),
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;
}

View File

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

View File

@@ -130,6 +130,17 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
return;
}
// Special handling for share intent deep links
// Share intents are handled by SharingIntentService showing a modal,
// not by routing to a page
if (path == '/share') {
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
windowManager.show();
}
return;
}
final router = ref.read(routerProvider);
if (uri.queryParameters.isNotEmpty) {
path =

View File

@@ -1,6 +1,6 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart';
@@ -28,12 +28,12 @@ Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
}
class AudioCallButton extends HookConsumerWidget {
final String roomId;
const AudioCallButton({super.key, required this.roomId});
final SnChatRoom room;
const AudioCallButton({super.key, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ongoingCall = ref.watch(ongoingCallProvider(roomId));
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier);
final isLoading = useState(false);
@@ -42,10 +42,9 @@ class AudioCallButton extends HookConsumerWidget {
Future<void> handleJoin() async {
isLoading.value = true;
try {
await apiClient.post('/sphere/chat/realtime/$roomId');
if (context.mounted) {
context.pushNamed('chatCall', pathParameters: {'id': roomId});
}
await apiClient.post('/sphere/chat/realtime/${room.id}');
// Just join the room, the overlay will handle the UI
await callNotifier.joinRoom(room);
} catch (e) {
showErrorAlert(e);
} finally {
@@ -56,7 +55,7 @@ class AudioCallButton extends HookConsumerWidget {
Future<void> handleEnd() async {
isLoading.value = true;
try {
await apiClient.delete('/sphere/chat/realtime/$roomId');
await apiClient.delete('/sphere/chat/realtime/${room.id}');
callNotifier.dispose(); // Clean up call resources
} catch (e) {
showErrorAlert(e);
@@ -94,9 +93,14 @@ class AudioCallButton extends HookConsumerWidget {
return IconButton(
icon: const Icon(Icons.call),
tooltip: 'Join Ongoing Call',
onPressed: () {
if (context.mounted) {
context.pushNamed('chatCall', pathParameters: {'id': roomId});
onPressed: () async {
isLoading.value = true;
try {
await callNotifier.joinRoom(room);
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
},
);
@@ -105,7 +109,7 @@ class AudioCallButton extends HookConsumerWidget {
// Show join/start call button
return IconButton(
icon: const Icon(Icons.call),
tooltip: 'Start/Join Call',
tooltip: 'Start Call',
onPressed: handleJoin,
);
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/call.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:styled_widget/styled_widget.dart';
class CallContent extends HookConsumerWidget {
const CallContent({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.watch(callNotifierProvider.notifier);
if (!callState.isConnected) {
return const Center(child: CircularProgressIndicator());
}
if (callNotifier.participants.isEmpty) {
return const Center(child: Text('No participants in call'));
}
final participants = callNotifier.participants;
final allAudioOnly = participants.every(
(p) =>
!(p.hasVideo &&
p.remoteParticipant.trackPublications.values.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO &&
!pub.muted &&
!pub.isDisposed,
)),
);
if (allAudioOnly) {
// Audio-only: show avatars in a compact row
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final live in participants)
SpeakingRippleAvatar(
live: live,
size: 72,
).padding(horizontal: 4),
],
),
),
);
}
// Show all participants in a responsive grid
return LayoutBuilder(
builder: (context, constraints) {
// Calculate width for responsive 2-column layout
final itemWidth = (constraints.maxWidth / 2) - 16;
return Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final participant in participants)
SizedBox(
width: itemWidth,
child: CallParticipantTile(live: participant),
),
],
);
},
);
}
}

View File

@@ -1,11 +1,18 @@
import 'package:animations/animations.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/chat/call.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/chat/call.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_content.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -13,7 +20,8 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:livekit_client/livekit_client.dart';
class CallControlsBar extends HookConsumerWidget {
const CallControlsBar({super.key});
final bool isCompact;
const CallControlsBar({super.key, this.isCompact = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -21,11 +29,14 @@ class CallControlsBar extends HookConsumerWidget {
final callNotifier = ref.read(callNotifierProvider.notifier);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 12 : 20,
vertical: isCompact ? 8 : 16,
),
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: 16,
spacing: 16,
runSpacing: isCompact ? 12 : 16,
spacing: isCompact ? 12 : 16,
children: [
_buildCircularButtonWithDropdown(
context: context,
@@ -73,12 +84,15 @@ class CallControlsBar extends HookConsumerWidget {
(innerContext) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Gap(24),
ListTile(
leading: const Icon(Symbols.logout, fill: 1),
title: Text('callLeave').tr(),
onTap: () {
callNotifier.disconnect();
Navigator.of(context).pop();
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
Navigator.of(innerContext).pop();
},
),
@@ -96,7 +110,9 @@ class CallControlsBar extends HookConsumerWidget {
);
callNotifier.dispose();
if (context.mounted) {
Navigator.of(context).pop();
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
Navigator.of(innerContext).pop();
}
} catch (err) {
@@ -124,12 +140,14 @@ class CallControlsBar extends HookConsumerWidget {
required Color backgroundColor,
Color? iconColor,
}) {
final size = isCompact ? 40.0 : 56.0;
final iconSize = isCompact ? 20.0 : 24.0;
return Container(
width: 56,
height: 56,
width: size,
height: size,
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: IconButton(
icon: Icon(icon, color: iconColor ?? Colors.white, size: 24),
icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
onPressed: onPressed,
),
);
@@ -145,41 +163,51 @@ class CallControlsBar extends HookConsumerWidget {
Color? iconColor,
String? deviceType, // 'videoinput' or 'audioinput'
}) {
final size = isCompact ? 40.0 : 56.0;
final iconSize = isCompact ? 20.0 : 24.0;
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 56,
height: 56,
width: size,
height: size,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(icon, color: iconColor ?? Colors.white, size: 24),
icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
onPressed: onPressed,
),
),
if (hasDropdown && deviceType != null)
Positioned(
bottom: 4,
right: 4,
child: GestureDetector(
onTap: () => _showDeviceSelectionDialog(context, ref, deviceType),
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 0.5,
bottom: 0,
right: isCompact ? 0 : -4,
child: Material(
color:
Colors
.transparent, // Make Material transparent to show underlying color
child: InkWell(
onTap:
() => _showDeviceSelectionDialog(context, ref, deviceType),
borderRadius: BorderRadius.circular((isCompact ? 16 : 24) / 2),
child: Container(
width: isCompact ? 16 : 24,
height: isCompact ? 16 : 24,
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 0.5,
),
),
child: Icon(
Icons.arrow_drop_down,
color: Colors.white,
size: isCompact ? 12 : 20,
),
),
child: Icon(
Icons.arrow_drop_down,
color: Colors.white,
size: 12,
),
),
),
@@ -279,34 +307,150 @@ class CallControlsBar extends HookConsumerWidget {
}
class CallOverlayBar extends HookConsumerWidget {
const CallOverlayBar({super.key});
final SnChatRoom room;
const CallOverlayBar({super.key, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier);
// Only show if connected and not on the call screen
if (!callState.isConnected) return const SizedBox.shrink();
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
// State for overlay mode: compact or preview
// Default to true (preview mode) so user sees video immediately after joining
final isExpanded = useState(true);
Widget child;
if (callState.isConnected) {
child = _buildActiveCallOverlay(
context,
ref,
callState,
callNotifier,
isExpanded,
);
} else if (ongoingCall.value != null) {
child = _buildJoinPrompt(context, ref);
} else {
child = const SizedBox.shrink(key: ValueKey('empty'));
}
return AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: child,
),
);
}
Widget _buildJoinPrompt(BuildContext context, WidgetRef ref) {
final isLoading = useState(false);
return Card(
key: const ValueKey('join_prompt'),
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.videocam,
color: Theme.of(context).colorScheme.onPrimary,
size: 20,
),
),
const Gap(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Call in progress').bold(),
Text('Tap to join', style: Theme.of(context).textTheme.bodySmall),
],
),
const Spacer(),
if (isLoading.value)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
).padding(right: 8)
else
FilledButton.icon(
onPressed: () async {
isLoading.value = true;
try {
// Just join the room, don't navigate
await ref.read(callNotifierProvider.notifier).joinRoom(room);
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
},
icon: const Icon(Icons.call, size: 18),
label: const Text('Join'),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
).padding(all: 12),
);
}
String _getChatRoomName(SnChatRoom? room, SnAccount currentUser) {
if (room == null) return 'unnamed'.tr();
return room.name ??
(room.members ?? [])
.where((element) => element.id != currentUser.id)
.map((element) => element.account.nick)
.first;
}
Widget _buildActiveCallOverlay(
BuildContext context,
WidgetRef ref,
CallState callState,
CallNotifier callNotifier,
ValueNotifier<bool> isExpanded,
) {
final lastSpeaker =
callNotifier.participants
.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
)
.isEmpty
? callNotifier.participants.first
? callNotifier.participants.firstOrNull
: callNotifier.participants
.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
)
.fold(
callNotifier.participants.first,
callNotifier.participants.firstOrNull,
(value, element) =>
element.remoteParticipant.lastSpokeAt != null &&
(value.remoteParticipant.lastSpokeAt == null ||
(value?.remoteParticipant.lastSpokeAt == null ||
element.remoteParticipant.lastSpokeAt!
.compareTo(
value
value!
.remoteParticipant
.lastSpokeAt!,
) >
@@ -315,11 +459,76 @@ class CallOverlayBar extends HookConsumerWidget {
: value,
);
final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
);
if (lastSpeaker == null) {
return const SizedBox.shrink(key: ValueKey('active_waiting'));
}
final userInfo = ref.watch(userInfoProvider).value!;
// Preview Mode (Expanded)
if (isExpanded.value) {
return Card(
key: const ValueKey('active_expanded'),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
const Gap(4),
Text(_getChatRoomName(callNotifier.chatRoom, userInfo)),
const Gap(4),
Text(formatDuration(callState.duration)).bold(),
const Spacer(),
OpenContainer(
closedElevation: 0,
closedColor: Colors.transparent,
openColor: Theme.of(context).scaffoldBackgroundColor,
middleColor: Theme.of(context).scaffoldBackgroundColor,
openBuilder: (context, action) => CallScreen(room: room),
closedBuilder:
(context, openContainer) => IconButton(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Icons.fullscreen),
onPressed: openContainer,
tooltip: 'Full Screen',
),
),
IconButton(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Icons.expand_less),
onPressed: () => isExpanded.value = false,
tooltip: 'Collapse',
),
],
).padding(horizontal: 12, vertical: 8),
// Video Preview
Container(
height: 200,
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const CallContent(),
),
const CallControlsBar(
isCompact: true,
).padding(vertical: 8, horizontal: 16),
],
),
);
}
// Compact Mode
return GestureDetector(
key: const ValueKey('active_collapsed'),
onTap: () => isExpanded.value = true,
child: Card(
margin: EdgeInsets.zero,
child: Row(
@@ -328,30 +537,32 @@ class CallOverlayBar extends HookConsumerWidget {
Expanded(
child: Row(
children: [
Builder(
builder: (context) {
if (callNotifier.localParticipant == null) {
return CircularProgressIndicator().center();
}
return SizedBox(
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
live: lastSpeaker,
size: 36,
).center(),
);
},
SizedBox(
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
live: lastSpeaker,
size: 36,
).center(),
),
const Gap(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('@${lastSpeaker.participant.identity}').bold(),
Text(
formatDuration(callState.duration),
style: Theme.of(context).textTheme.bodySmall,
Row(
spacing: 4,
children: [
Text(
_getChatRoomName(callNotifier.chatRoom, userInfo),
style: Theme.of(context).textTheme.bodySmall,
),
Text(
formatDuration(callState.duration),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
@@ -361,41 +572,20 @@ class CallOverlayBar extends HookConsumerWidget {
IconButton(
icon: Icon(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
size: 20,
),
onPressed: () {
callNotifier.toggleMicrophone();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
),
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
),
onPressed: () {
callNotifier.toggleScreenShare(context);
},
style: actionButtonStyle,
icon: const Icon(Icons.expand_more),
onPressed: () => isExpanded.value = true,
tooltip: 'Expand',
),
],
).padding(all: 16),
).padding(all: 12),
),
onTap: () {
context.pushNamed(
'chatCall',
pathParameters: {'id': callNotifier.roomId!},
);
},
);
}
}

View File

@@ -8,6 +8,59 @@ import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class SpeakingRipple extends StatelessWidget {
final double size;
final double audioLevel;
final bool isSpeaking;
final Widget child;
const SpeakingRipple({
super.key,
required this.size,
required this.audioLevel,
required this.isSpeaking,
required this.child,
});
@override
Widget build(BuildContext context) {
final avatarRadius = size / 2;
final clampedLevel = audioLevel.clamp(0.0, 1.0);
final rippleRadius = avatarRadius + clampedLevel * (size * 0.333);
return SizedBox(
width: size + 8,
height: size + 8,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: avatarRadius,
end: isSpeaking ? rippleRadius : avatarRadius,
),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
builder: (context, animatedRadius, child) {
return Stack(
alignment: Alignment.center,
children: [
if (isSpeaking)
Container(
width: animatedRadius * 2,
height: animatedRadius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel),
),
),
child!,
],
);
},
child: SizedBox(width: size, height: size, child: child),
),
);
}
}
class SpeakingRippleAvatar extends HookConsumerWidget {
final CallParticipantLive live;
final double size;
@@ -18,79 +71,58 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final account = ref.watch(accountProvider(live.participant.identity));
final avatarRadius = size / 2;
final clampedLevel = live.remoteParticipant.audioLevel.clamp(0.0, 1.0);
final rippleRadius = avatarRadius + clampedLevel * (size * 0.333);
return SizedBox(
width: size + 8,
height: size + 8,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: avatarRadius,
end: live.remoteParticipant.isSpeaking ? rippleRadius : avatarRadius,
),
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
builder: (context, animatedRadius, child) {
return Stack(
return SpeakingRipple(
size: size,
audioLevel: live.remoteParticipant.audioLevel,
isSpeaking: live.remoteParticipant.isSpeaking,
child: Stack(
children: [
Container(
width: size,
height: size,
alignment: Alignment.center,
children: [
if (live.remoteParticipant.isSpeaking)
Container(
width: animatedRadius * 2,
height: animatedRadius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel),
decoration: const BoxDecoration(shape: BoxShape.circle),
child: account.when(
data:
(value) => CallParticipantGestureDetector(
participant: live,
child: ProfilePictureWidget(
file: value.profile.picture,
radius: size / 2,
),
),
error:
(_, _) => CircleAvatar(
radius: size / 2,
child: const Icon(Symbols.person_remove),
),
loading:
() => CircleAvatar(
radius: size / 2,
child: CircularProgressIndicator(),
),
),
),
if (live.remoteParticipant.isMuted)
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 2),
),
Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(shape: BoxShape.circle),
child: account.when(
data:
(value) => CallParticipantGestureDetector(
participant: live,
child: ProfilePictureWidget(
file: value.profile.picture,
radius: size / 2,
),
),
error:
(_, _) => CircleAvatar(
radius: size / 2,
child: const Icon(Symbols.person_remove),
),
loading:
() => CircleAvatar(
radius: size / 2,
child: CircularProgressIndicator(),
),
child: const Icon(
Symbols.mic_off,
size: 14,
color: Colors.white,
),
),
if (live.remoteParticipant.isMuted)
Positioned(
bottom: 4,
right: 4,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child: const Icon(
Symbols.mic_off,
size: 14,
fill: 1,
).padding(left: 1.5, top: 1.5),
),
),
],
);
},
),
],
),
);
}
@@ -103,6 +135,8 @@ class CallParticipantTile extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(accountProvider(live.participant.name));
final hasVideo =
live.hasVideo &&
live.remoteParticipant.trackPublications.values
@@ -110,42 +144,92 @@ class CallParticipantTile extends HookConsumerWidget {
.isNotEmpty;
if (hasVideo) {
return Stack(
fit: StackFit.loose,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: VideoTrackRenderer(
live.remoteParticipant.trackPublications.values
.where((track) => track.kind == TrackType.VIDEO)
.first
.track
as VideoTrack,
renderMode: VideoRenderMode.platformView,
),
),
Positioned(
left: 8,
right: 8,
bottom: 8,
child: Text(
'@${live.participant.name}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: Colors.white,
shadows: [
BoxShadow(
color: Colors.black54,
offset: Offset(1, 1),
spreadRadius: 8,
blurRadius: 8,
),
],
return Padding(
padding: const EdgeInsets.all(8),
child: LayoutBuilder(
builder: (context, constraints) {
// Use the smaller dimension to determine the "size" for the ripple calculation
// effectively making the ripple relative to the tile size.
// However, for a rectangular video, we might want a different approach.
// The user asked for "speaking ripple to the video as well".
// If we use the extracted SpeakingRipple, it expects a size and assumes a circle.
// We need to adapt it or create a rectangular version.
// Given the "image" likely shows a rectangular video with rounded corners,
// let's create a specific wrapper for the video tile that adds a border/glow when speaking.
final isSpeaking = live.remoteParticipant.isSpeaking;
final audioLevel = live.remoteParticipant.audioLevel;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
isSpeaking
? Colors.green.withOpacity(
0.5 + 0.5 * audioLevel.clamp(0.0, 1.0),
)
: Theme.of(context).colorScheme.outlineVariant,
width: isSpeaking ? 4 : 1,
),
),
),
),
],
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: [
VideoTrackRenderer(
live.remoteParticipant.trackPublications.values
.where((track) => track.kind == TrackType.VIDEO)
.first
.track
as VideoTrack,
renderMode: VideoRenderMode.platformView,
),
Positioned(
left: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.6),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (live.remoteParticipant.isMuted)
const Icon(
Symbols.mic_off,
size: 14,
color: Colors.redAccent,
).padding(right: 4),
Text(
userInfo.value?.nick ?? live.participant.name,
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
),
);
},
),
);
} else {
return SpeakingRippleAvatar(size: 84, live: live);

View File

@@ -45,6 +45,8 @@ void _insertPlaceholder(TextEditingController controller, String placeholder) {
const kInputDrawerExpandedHeight = 180.0;
const kExpandedSectionTabHeight = 32.0;
class _ExpandedSection extends StatelessWidget {
final TextEditingController messageController;
final SnPoll? selectedPoll;
@@ -75,9 +77,23 @@ class _ExpandedSection extends StatelessWidget {
length: 2,
child: Column(
children: [
TabBar(
splashBorderRadius: const BorderRadius.all(Radius.circular(40)),
tabs: [Tab(text: 'Features'), Tab(text: 'Stickers')],
PreferredSize(
preferredSize: const Size.fromHeight(kExpandedSectionTabHeight),
child: TabBar(
splashBorderRadius: const BorderRadius.all(
Radius.circular(40),
),
tabs: [
Tab(
text: 'features'.tr(),
height: kExpandedSectionTabHeight,
),
Tab(
text: 'stickers'.tr(),
height: kExpandedSectionTabHeight,
),
],
),
),
SizedBox(
height: kInputDrawerExpandedHeight,
@@ -248,6 +264,7 @@ class ChatInput extends HookConsumerWidget {
void send() {
inputFocusNode.requestFocus();
if (isExpanded.value) isExpanded.value = false;
onSend.call();
}

View File

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

View File

@@ -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,

View File

@@ -1,64 +0,0 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
import 'package:island/talker.dart';
String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
return message ?? err.toString();
}
void showErrorAlert(dynamic err) async {
if (err is Error) {
talker.error('Something went wrong...', err, err.stackTrace);
}
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),
Exception _ => err.toString(),
_ => err.toString(),
};
FlutterPlatformAlert.showAlert(
windowTitle: 'somethingWentWrong'.tr(),
text: text,
alertStyle: AlertButtonStyle.ok,
iconStyle: IconStyle.error,
);
}
void showInfoAlert(String message, String title) async {
FlutterPlatformAlert.showAlert(
windowTitle: title,
text: message,
alertStyle: AlertButtonStyle.ok,
iconStyle: IconStyle.information,
);
}
Future<bool> showConfirmAlert(String message, String title) async {
final result = await FlutterPlatformAlert.showAlert(
windowTitle: title,
text: message,
alertStyle: AlertButtonStyle.okCancel,
iconStyle: IconStyle.question,
);
return result == AlertButton.okButton;
}

View File

@@ -1,53 +0,0 @@
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:js' as js;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
return message ?? err.toString();
}
void showErrorAlert(dynamic err) async {
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),
Exception _ => err.toString(),
_ => err.toString(),
};
js.context.callMethod('swal', ['somethingWentWrong'.tr(), text, 'error']);
}
void showInfoAlert(String message, String title) async {
js.context.callMethod('swal', [title, message, 'info']);
}
Future<bool> showConfirmAlert(String message, String title) async {
final result = await js.context.callMethod('swal', [
title,
message,
'question',
{'buttons': true},
]);
return result == true;
}

View File

@@ -215,6 +215,7 @@ class CloudFileList extends HookConsumerWidget {
}
if (files.length == 1) {
final isImage = files.first.mimeType?.startsWith('image') ?? false;
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
final ratio = files.first.fileMeta?['ratio'] as num?;
final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -246,6 +247,8 @@ class CloudFileList extends HookConsumerWidget {
child:
(ratio == null && isImage)
? IntrinsicWidth(child: IntrinsicHeight(child: widgetItem))
: (ratio == null && isAudio)
? IntrinsicHeight(child: widgetItem)
: AspectRatio(
aspectRatio: ratio?.toDouble() ?? 1,
child: widgetItem,

View File

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

View File

@@ -166,7 +166,6 @@ class MarkdownTextContent extends HookConsumerWidget {
label: 'copyToClipboard'.tr(),
onPressed: () {
Clipboard.setData(ClipboardData(text: href));
clearSnackBar(context);
},
),
);

View File

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

View File

@@ -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;

View File

@@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -56,9 +57,7 @@ class ComposeEmbedSheet extends HookConsumerWidget {
void saveEmbedView() {
final uri = uriController.text.trim();
if (uri.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('embedUriRequired'.tr())));
showSnackBar('embedUriRequired'.tr());
return;
}

View File

@@ -751,12 +751,7 @@ class ComposeLogic {
return post;
} catch (err) {
// Show error message if context is mounted
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $err')));
}
showErrorAlert(err);
rethrow;
} finally {
state.submitting.value = false;

View File

@@ -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: () {

View File

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

View File

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

View File

@@ -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(),

View File

@@ -631,12 +631,33 @@ class CustomReactionForm extends HookConsumerWidget {
),
suffixIcon: InkWell(
onTapDown: (details) async {
final screenSize = MediaQuery.sizeOf(context);
const popoverWidth = 500.0;
const popoverHeight = 500.0;
const padding = 20.0;
// Calculate safe horizontal position (centered, but within bounds)
final maxHorizontalOffset = math.max(
padding,
screenSize.width - popoverWidth - padding,
);
final horizontalOffset = ((screenSize.width - popoverWidth) /
2)
.clamp(padding, maxHorizontalOffset);
// Calculate safe vertical position (bottom-aligned, but within bounds)
final maxVerticalOffset = math.max(
padding,
screenSize.height - popoverHeight - padding,
);
final verticalOffset = (screenSize.height -
popoverHeight -
padding)
.clamp(padding, maxVerticalOffset);
await showStickerPickerPopover(
context,
Offset(
(MediaQuery.sizeOf(context).width - 500) / 2,
MediaQuery.sizeOf(context).height - 500,
),
Offset(horizontalOffset, verticalOffset),
alignment: Alignment.topLeft,
onPick: (placeholder) {
// Remove the surrounding : from the placeholder

View File

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

View File

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

View File

@@ -0,0 +1,289 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:flutter_highlight/themes/monokai-sublime.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/site_file.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/site_files.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
class FileItem extends HookConsumerWidget {
final SnSiteFileEntry file;
final SnPublicationSite site;
final void Function(String path)? onNavigateDirectory;
const FileItem({
super.key,
required this.file,
required this.site,
this.onNavigateDirectory,
});
Future<void> _downloadFile(BuildContext context, WidgetRef ref) async {
try {
final apiClient = ref.read(apiClientProvider);
// Get downloads directory
Directory? directory;
if (Platform.isAndroid) {
directory = await getExternalStorageDirectory();
if (directory != null) {
directory = Directory('${directory.path}/Download');
}
} else {
directory = await getDownloadsDirectory();
}
if (directory == null) {
throw Exception('Unable to access downloads directory');
}
// Create directory if it doesn't exist
await directory.create(recursive: true);
// Generate file path
final fileName = file.relativePath.split('/').last;
final filePath = '${directory.path}/$fileName';
// Use Dio's download method to directly stream from server to file
await apiClient.download(
'/zone/sites/${site.id}/files/content/${file.relativePath}',
filePath,
);
showSnackBar('Downloaded to $filePath');
} catch (e) {
showErrorAlert(e);
}
}
Future<void> _showEditSheet(BuildContext context, WidgetRef ref) async {
try {
final fileContent = await ref.read(
siteFileContentProvider(
siteId: site.id,
relativePath: file.relativePath,
).future,
);
if (context.mounted) {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: false,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height,
),
barrierColor: Theme.of(context).colorScheme.surfaceContainerLow,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
builder: (BuildContext context) {
return FileEditorSheet(
file: file,
site: site,
initialContent: fileContent.content,
);
},
);
}
} catch (e) {
showErrorAlert(e);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
elevation: 0,
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
leading: Icon(
file.isDirectory ? Symbols.folder : Symbols.description,
color: theme.colorScheme.primary,
),
title: Text(file.relativePath),
subtitle: Text(
file.isDirectory
? 'Directory'
: '${(file.size / 1024).toStringAsFixed(1)} KB',
),
trailing: PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'download',
child: Row(
children: [
const Icon(Symbols.download),
const Gap(16),
Text('Download'),
],
),
),
if (!file.isDirectory) ...[
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('Edit Content'),
],
),
),
],
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('Delete').textColor(Colors.red),
],
),
),
],
onSelected: (value) async {
switch (value) {
case 'download':
await _downloadFile(context, ref);
break;
case 'edit':
await _showEditSheet(context, ref);
break;
case 'delete':
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete File'),
content: Text(
'Are you sure you want to delete "${file.relativePath}"?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
await ref
.read(
siteFilesNotifierProvider((
siteId: site.id,
path: null,
)).notifier,
)
.deleteFile(file.relativePath);
showSnackBar('File deleted successfully');
} catch (e) {
showErrorAlert(e);
}
}
break;
}
},
),
onTap: () {
if (file.isDirectory) {
onNavigateDirectory?.call(file.relativePath);
} else {
_showEditSheet(context, ref);
}
},
),
);
}
}
class FileEditorSheet extends HookConsumerWidget {
final SnSiteFileEntry file;
final SnPublicationSite site;
final String initialContent;
const FileEditorSheet({
super.key,
required this.file,
required this.site,
required this.initialContent,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final codeController = useMemoized(
() => CodeController(
text: initialContent,
language: null, // Let the editor auto-detect or use plain text
),
);
final isSaving = useState(false);
final saveFile = useCallback(() async {
if (codeController.text.trim().isEmpty) {
showSnackBar('contentCantEmpty'.tr());
return;
}
isSaving.value = true;
try {
await ref
.read(
siteFilesNotifierProvider((siteId: site.id, path: null)).notifier,
)
.updateFileContent(file.relativePath, codeController.text);
if (context.mounted) {
showSnackBar('File saved successfully');
Navigator.of(context).pop();
}
} catch (e) {
showErrorAlert(e);
} finally {
isSaving.value = false;
}
}, [codeController, ref, site.id, file.relativePath, context, isSaving]);
return SheetScaffold(
heightFactor: 1,
titleText: 'Edit ${file.relativePath}',
actions: [
FilledButton(
onPressed: isSaving.value ? null : saveFile,
child: Text(isSaving.value ? 'Saving...' : 'Save'),
),
],
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: CodeTheme(
data: CodeThemeData(styles: monokaiSublimeTheme),
child: CodeField(
controller: codeController,
minLines: 20,
maxLines: null,
),
),
),
);
}
}

View File

@@ -0,0 +1,139 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:dio/dio.dart';
import 'package:http_parser/http_parser.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/pods/site_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class FileManagementActionSection extends HookConsumerWidget {
final SnPublicationSite site;
final String pubName;
const FileManagementActionSection({
super.key,
required this.site,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
child: Column(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fileActions'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
).padding(horizontal: 16, top: 16),
Column(
children: [
ListTile(
leading: Icon(
Symbols.delete_forever,
color: theme.colorScheme.error,
),
title: Text('purgeFiles'.tr()),
subtitle: Text('purgeFilesDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () => _purgeFiles(context, ref),
),
const Gap(8),
ListTile(
leading: Icon(
Symbols.upload,
color: theme.colorScheme.primary,
),
title: Text('deploySite'.tr()),
subtitle: Text('deploySiteDescription'.tr()),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () => _deploySite(context, ref),
),
],
).padding(vertical: 8),
],
),
],
),
);
}
Future<void> _purgeFiles(BuildContext context, WidgetRef ref) async {
final confirmed = await showConfirmAlert(
'purgeFilesConfirm'.tr(),
'confirmPurge'.tr(),
isDanger: true,
);
if (confirmed != true) return;
try {
final apiClient = ref.read(apiClientProvider);
await apiClient.delete('/zone/sites/${site.id}/files/purge');
if (context.mounted) {
showSnackBar('allFilesPurgedSuccess'.tr());
// Refresh the file management section
ref.invalidate(siteFilesProvider(siteId: site.id));
}
} catch (e) {
if (context.mounted) {
showSnackBar('failedToPurgeFiles'.tr(args: [e.toString()]));
}
}
}
Future<void> _deploySite(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['zip'],
allowMultiple: false,
);
if (result == null || result.files.isEmpty) {
return; // User canceled
}
final file = File(result.files.first.path!);
try {
final apiClient = ref.read(apiClientProvider);
// Create multipart form data
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(
file.path,
filename: result.files.first.name,
contentType: MediaType('application', 'zip'),
),
});
await apiClient.post(
'/zone/sites/${site.id}/files/deploy',
data: formData,
);
if (context.mounted) {
showSnackBar('siteDeployedSuccess'.tr());
// Refresh the file management section
ref.invalidate(siteFilesProvider(siteId: site.id));
}
} catch (e) {
if (context.mounted) {
showSnackBar('failedToDeploySite'.tr(args: [e.toString()]));
}
}
}
}

View File

@@ -0,0 +1,312 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/site_files.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/sites/file_upload_dialog.dart';
import 'package:island/widgets/sites/file_item.dart';
import 'package:material_symbols_icons/symbols.dart';
class FileManagementSection extends HookConsumerWidget {
final SnPublicationSite site;
final String pubName;
const FileManagementSection({
super.key,
required this.site,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentPath = useState<String?>(null);
final filesAsync = ref.watch(
siteFilesProvider(siteId: site.id, path: currentPath.value),
);
final theme = Theme.of(context);
return Card(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.folder, size: 20),
const Gap(8),
Text(
'fileManagement'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
PopupMenuButton<String>(
icon: const Icon(Symbols.upload),
onSelected: (String choice) async {
List<File> files = [];
List<Map<String, dynamic>>? results;
if (choice == 'files') {
final selectedFiles = await FilePicker.platform
.pickFiles(
allowMultiple: true,
type: FileType.any,
);
if (selectedFiles == null ||
selectedFiles.files.isEmpty) {
return; // User canceled
}
files =
selectedFiles.files
.map((f) => File(f.path!))
.toList();
} else if (choice == 'folder') {
final dirPath =
await FilePicker.platform.getDirectoryPath();
if (dirPath == null) return;
results = await _getFilesRecursive(dirPath);
files =
results.map((m) => m['file'] as File).toList();
if (files.isEmpty) {
showSnackBar('noFilesFoundInFolder'.tr());
return;
}
}
if (!context.mounted) return;
// Show upload dialog for path specification
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => FileUploadDialog(
selectedFiles: files,
site: site,
relativePaths:
results
?.map(
(m) => m['relativePath'] as String,
)
.toList(),
onUploadComplete: () {
// Refresh file list
ref.invalidate(
siteFilesProvider(
siteId: site.id,
path: currentPath.value,
),
);
},
),
);
},
itemBuilder:
(BuildContext context) => [
PopupMenuItem<String>(
value: 'files',
child: Row(
children: [
Icon(Symbols.file_copy),
Gap(12),
Text('siteFiles'.tr()),
],
),
),
PopupMenuItem<String>(
value: 'folder',
child: Row(
children: [
Icon(Symbols.folder),
Gap(12),
Text('siteFolder'.tr()),
],
),
),
],
style: ButtonStyle(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
),
],
),
const Gap(8),
if (currentPath.value != null && currentPath.value!.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(16),
),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
IconButton(
icon: Icon(Symbols.arrow_back),
onPressed: () {
final pathParts =
currentPath.value!
.split('/')
.where((part) => part.isNotEmpty)
.toList();
if (pathParts.isEmpty) {
currentPath.value = null;
} else {
pathParts.removeLast();
currentPath.value =
pathParts.isEmpty
? null
: pathParts.join('/');
}
},
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
InkWell(
onTap: () => currentPath.value = null,
child: Text('siteRoot'.tr()),
),
...() {
final parts =
currentPath.value!
.split('/')
.where((part) => part.isNotEmpty)
.toList();
final widgets = <Widget>[];
String currentBuilder = '';
for (final part in parts) {
currentBuilder +=
(currentBuilder.isEmpty ? '' : '/') +
part;
final pathToSet = currentBuilder;
widgets.addAll([
const Text(' / '),
InkWell(
onTap:
() => currentPath.value = pathToSet,
child: Text(part),
),
]);
}
return widgets;
}(),
],
),
),
],
),
),
const Gap(8),
filesAsync.when(
data: (files) {
if (files.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Symbols.folder,
size: 48,
color: theme.colorScheme.outline,
),
const Gap(16),
Text(
'noFilesUploadedYet'.tr(),
style: theme.textTheme.bodyLarge,
),
const Gap(8),
Text(
'uploadFirstFile'.tr(),
style: theme.textTheme.bodySmall,
),
],
),
),
);
}
return ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: files.length,
itemBuilder: (context, index) {
final file = files[index];
return FileItem(
file: file,
site: site,
onNavigateDirectory:
(path) => currentPath.value = path,
);
},
);
},
loading:
() => const Center(child: CircularProgressIndicator()),
error:
(error, stack) => Center(
child: Column(
children: [
Text('failedToLoadFiles'.tr()),
const Gap(8),
ElevatedButton(
onPressed:
() => ref.invalidate(
siteFilesProvider(
siteId: site.id,
path: currentPath.value,
),
),
child: Text('retry'.tr()),
),
],
),
),
),
],
),
),
],
),
);
}
Future<List<Map<String, dynamic>>> _getFilesRecursive(String dirPath) async {
final List<Map<String, dynamic>> results = [];
try {
await for (final entity in Directory(dirPath).list(recursive: true)) {
if (entity is File) {
String relativePath = entity.path.substring(dirPath.length);
if (relativePath.startsWith('/')) {
relativePath = relativePath.substring(1);
}
if (relativePath.isEmpty) continue;
results.add({
'file': File(entity.path),
'relativePath': relativePath,
});
}
}
} catch (e) {
// Handle error if needed
}
return results;
}
}

View File

@@ -0,0 +1,288 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/site_files.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
class FileUploadDialog extends HookConsumerWidget {
final List<File> selectedFiles;
final SnPublicationSite site;
final VoidCallback onUploadComplete;
final List<String>? relativePaths;
const FileUploadDialog({
super.key,
required this.selectedFiles,
required this.site,
required this.onUploadComplete,
this.relativePaths,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final pathController = useTextEditingController(text: '/');
final isUploading = useState(false);
final progressStates = useState<List<Map<String, dynamic>>>(
selectedFiles
.map(
(file) => {
'fileName':
relativePaths?[selectedFiles.indexOf(file)] ??
file.path.split('/').last,
'progress': 0.0,
'status':
'pending', // 'pending', 'uploading', 'completed', 'error'
'error': null,
},
)
.toList(),
);
// Calculate overall progress
final overallProgress =
progressStates.value.isNotEmpty
? progressStates.value
.map((e) => e['progress'] as double)
.reduce((a, b) => a + b) /
progressStates.value.length
: 0.0;
final overallStatus =
progressStates.value.isEmpty
? 'pending'
: progressStates.value.every((e) => e['status'] == 'completed')
? 'completed'
: progressStates.value.any((e) => e['status'] == 'error')
? 'error'
: progressStates.value.any((e) => e['status'] == 'uploading')
? 'uploading'
: 'pending';
final uploadFile = useCallback((
String basePath,
File file,
int index,
) async {
try {
progressStates.value[index]['status'] = 'uploading';
progressStates.value = [...progressStates.value];
final siteFilesNotifier = ref.read(
siteFilesNotifierProvider((siteId: site.id, path: null)).notifier,
);
final fileName = relativePaths?[index] ?? file.path.split('/').last;
final uploadPath =
basePath.endsWith('/')
? '$basePath$fileName'
: '$basePath/$fileName';
await siteFilesNotifier.uploadFile(file, uploadPath);
progressStates.value[index]['status'] = 'completed';
progressStates.value[index]['progress'] = 1.0;
progressStates.value = [...progressStates.value];
} catch (e) {
progressStates.value[index]['status'] = 'error';
progressStates.value[index]['error'] = e.toString();
progressStates.value = [...progressStates.value];
}
}, [ref, site.id, progressStates]);
final uploadAllFiles = useCallback(
() async {
if (!formKey.currentState!.validate()) return;
isUploading.value = true;
// Reset all progress states
for (int i = 0; i < progressStates.value.length; i++) {
progressStates.value[i]['status'] = 'pending';
progressStates.value[i]['progress'] = 0.0;
progressStates.value[i]['error'] = null;
}
progressStates.value = [...progressStates.value];
// Upload files sequentially (could be made parallel if needed)
for (int i = 0; i < selectedFiles.length; i++) {
final file = selectedFiles[i];
await uploadFile(pathController.text, file, i);
}
isUploading.value = false;
// Close dialog if all uploads completed successfully
if (progressStates.value.every(
(state) => state['status'] == 'completed',
)) {
if (context.mounted) {
showSnackBar('All files uploaded successfully');
onUploadComplete();
Navigator.of(context).pop();
}
}
},
[
uploadFile,
isUploading,
progressStates,
selectedFiles,
onUploadComplete,
context,
formKey,
pathController,
],
);
return SheetScaffold(
titleText: 'Upload Files',
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Upload path field
TextFormField(
controller: pathController,
decoration: const InputDecoration(
labelText: 'Upload Path',
hintText: '/ (root) or /assets/images/',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an upload path';
}
if (!value.startsWith('/') && value != '/') {
return 'Path must start with /';
}
if (value.contains(' ')) {
return 'Path cannot contain spaces';
}
if (value.contains('//')) {
return 'Path cannot have consecutive slashes';
}
return null;
},
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(16),
Card(
child: Column(
children: [
// Overall progress
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${(overallProgress * 100).toStringAsFixed(0)}% completed',
style: Theme.of(context).textTheme.titleSmall,
),
const Gap(8),
LinearProgressIndicator(value: overallProgress),
const Gap(8),
Text(
_getOverallStatusText(overallStatus),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
// Divider
const Divider(height: 0),
// File list in expansion
ExpansionTile(
title: Text('${selectedFiles.length} files to upload'),
initiallyExpanded: selectedFiles.length <= 10,
children:
selectedFiles.map((file) {
final index = selectedFiles.indexOf(file);
final progressState = progressStates.value[index];
final displayName =
progressState['fileName'] as String;
return ListTile(
leading: _getStatusIcon(
progressState['status'] as String,
),
title: Text(displayName),
subtitle: Text(
'Size: ${(file.lengthSync() / 1024).toStringAsFixed(1)} KB',
),
dense: true,
);
}).toList(),
),
],
),
),
const Gap(24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed:
isUploading.value
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
),
const Gap(12),
Expanded(
child: FilledButton(
onPressed: isUploading.value ? null : uploadAllFiles,
child: Text(
isUploading.value
? 'Uploading...'
: 'Upload ${selectedFiles.length} File${selectedFiles.length == 1 ? '' : 's'}',
),
),
),
],
),
],
),
),
),
);
}
Icon _getStatusIcon(String status) {
switch (status) {
case 'completed':
return const Icon(Symbols.check_circle, color: Colors.green);
case 'uploading':
return const Icon(Symbols.sync);
case 'error':
return const Icon(Symbols.error, color: Colors.red);
default:
return const Icon(Symbols.pending);
}
}
String _getOverallStatusText(String status) {
switch (status) {
case 'completed':
return 'All uploads completed';
case 'error':
return 'Some uploads failed';
case 'uploading':
return 'Uploading in progress';
default:
return 'Ready to upload';
}
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
class InfoRow extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final bool monospace;
final VoidCallback? onTap;
const InfoRow({
super.key,
required this.label,
required this.value,
required this.icon,
this.monospace = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
Widget valueWidget = Text(
value,
style:
monospace
? GoogleFonts.robotoMono(fontSize: 14)
: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.end,
);
if (onTap != null) valueWidget = InkWell(onTap: onTap, child: valueWidget);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const Gap(12),
Expanded(
flex: 2,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
const Gap(12),
Expanded(flex: 3, child: valueWidget),
],
);
}
}

View File

@@ -0,0 +1,397 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/publication_site.dart';
import 'package:island/pods/site_pages.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class PageForm extends HookConsumerWidget {
final SnPublicationSite site;
final String pubName;
final SnPublicationPage? page; // null for create, non-null for edit
const PageForm({
super.key,
required this.site,
required this.pubName,
this.page,
});
int _getPageType(SnPublicationPage? page) {
if (page == null) return 0; // Default to HTML
// Check config structure to determine type
return page.config?.containsKey('target') == true ? 1 : 0;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final pathController = useTextEditingController(text: page?.path ?? '/');
// Determine initial type and create appropriate controllers
final initialType = _getPageType(page);
final pageType = useState(initialType);
final htmlController = useTextEditingController(
text:
pageType.value == 0
? (page?.config?['html'] ?? page?.config?['content'] ?? '')
: '',
);
final titleController = useTextEditingController(
text: pageType.value == 0 ? (page?.config?['title'] ?? '') : '',
);
final targetController = useTextEditingController(
text: pageType.value == 1 ? (page?.config?['target'] ?? '') : '',
);
final isLoading = useState(false);
// Update controllers when page type changes
useEffect(() {
pageType.addListener(() {
if (pageType.value == 0) {
// HTML mode
htmlController.text =
page?.config?['html'] ?? page?.config?['content'] ?? '';
titleController.text = page?.config?['title'] ?? '';
targetController.clear();
} else {
// Redirect mode
htmlController.clear();
titleController.clear();
targetController.text = page?.config?['target'] ?? '';
}
});
return null;
}, [pageType]);
// Initialize form fields when page data is loaded
useEffect(() {
if (page?.path != null && pathController.text == '/') {
pathController.text = page!.path!;
if (pageType.value == 0) {
htmlController.text =
page!.config?['html'] ?? page!.config?['content'] ?? '';
titleController.text = page!.config?['title'] ?? '';
} else {
targetController.text = page!.config?['target'] ?? '';
}
}
return null;
}, [page]);
final savePage = useCallback(() async {
if (!formKey.currentState!.validate()) return;
isLoading.value = true;
try {
final pagesNotifier = ref.read(
sitePagesNotifierProvider((
pubName: pubName,
siteSlug: site.slug,
)).notifier,
);
late final Map<String, dynamic> pageData;
if (pageType.value == 0) {
// HTML page
pageData = {
'type': 0,
'path': pathController.text,
'config': {
'title': titleController.text,
'html': htmlController.text,
},
};
} else {
// Redirect page
pageData = {
'type': 1,
'path': pathController.text,
'config': {'target': targetController.text},
};
}
if (page == null) {
// Create new page
await pagesNotifier.createPage(pageData);
} else {
// Update existing page
await pagesNotifier.updatePage(page!.id, pageData);
}
if (context.mounted) {
showSnackBar(
page == null
? 'Page created successfully'
: 'Page updated successfully',
);
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pageType, pubName, site.slug, page]);
final deletePage = useCallback(() async {
if (page == null) return; // Shouldn't happen for editing
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete Page'),
content: const Text('Are you sure you want to delete this page?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
isLoading.value = true;
try {
final pagesNotifier = ref.read(
sitePagesNotifierProvider((
pubName: pubName,
siteSlug: site.slug,
)).notifier,
);
await pagesNotifier.deletePage(page!.id);
if (context.mounted) {
showSnackBar('Page deleted successfully');
Navigator.pop(context);
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}, [pubName, site.slug, page, context]);
return SheetScaffold(
titleText: page == null ? 'Create Page' : 'Edit Page',
child: SingleChildScrollView(
child: Column(
children: [
Form(
key: formKey,
child: Column(
children: [
// Page type selector
DropdownButtonFormField<int>(
value: pageType.value,
decoration: const InputDecoration(
labelText: 'Page Type',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
items: const [
DropdownMenuItem(
value: 0,
child: Row(
children: [
Icon(Symbols.code, size: 20),
Gap(8),
Text('HTML Page'),
],
),
),
DropdownMenuItem(
value: 1,
child: Row(
children: [
Icon(Symbols.link, size: 20),
Gap(8),
Text('Redirect Page'),
],
),
),
],
onChanged: (value) {
if (value != null) {
pageType.value = value;
}
},
validator: (value) {
if (value == null) {
return 'Please select a page type';
}
return null;
},
).padding(all: 20),
// Conditional form fields based on page type
if (pageType.value == 0) ...[
// HTML Page fields
TextFormField(
controller: pathController,
decoration: const InputDecoration(
labelText: 'Page Path',
hintText: '/about, /contact, etc.',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a page path';
}
if (!RegExp(r'^[a-zA-Z0-9\-/_]+$').hasMatch(value)) {
return 'Page path can only contain letters, numbers, hyphens, underscores, and slashes';
}
if (!value.startsWith('/')) {
return 'Page path must start with /';
}
if (value.contains('//')) {
return 'Page path cannot have consecutive slashes';
}
return null;
},
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 20),
const SizedBox(height: 16),
TextFormField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Page Title',
hintText: 'About Us, Contact, etc.',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a page title';
}
return null;
},
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 20),
const SizedBox(height: 16),
TextFormField(
controller: htmlController,
decoration: const InputDecoration(
labelText: 'Page Content (HTML)',
hintText:
'<h1>Hello World</h1><p>This is my page content...</p>',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
alignLabelWithHint: true,
),
maxLines: 10,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter HTML content for the page';
}
return null;
},
).padding(horizontal: 20),
] else ...[
// Redirect Page fields
TextFormField(
controller: pathController,
decoration: const InputDecoration(
labelText: 'Page Path',
hintText: '/old-page, /redirect, etc.',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a page path';
}
if (!RegExp(r'^[a-zA-Z0-9\-/_]+$').hasMatch(value)) {
return 'Page path can only contain letters, numbers, hyphens, underscores, and slashes';
}
if (!value.startsWith('/')) {
return 'Page path must start with /';
}
if (value.contains('//')) {
return 'Page path cannot have consecutive slashes';
}
return null;
},
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 20),
const SizedBox(height: 16),
TextFormField(
controller: targetController,
decoration: const InputDecoration(
labelText: 'Redirect Target',
hintText: '/new-page, https://example.com, etc.',
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a redirect target';
}
if (!value.startsWith('/') &&
!value.startsWith('http://') &&
!value.startsWith('https://')) {
return 'Target must be a relative path (/) or absolute URL (http/https)';
}
return null;
},
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 20),
Row(
children: [
if (page != null) ...[
TextButton.icon(
onPressed: deletePage,
icon: const Icon(Symbols.delete_forever),
label: const Text('Delete Page'),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
).alignment(Alignment.centerRight),
const Spacer(),
] else
const Spacer(),
TextButton.icon(
onPressed: savePage,
icon: const Icon(Symbols.save),
label: const Text('Save Page'),
),
],
).padding(horizontal: 20, vertical: 16),
],
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,125 @@
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/pods/site_pages.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/sites/page_form.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
class PageItem extends HookConsumerWidget {
final SnPublicationPage page;
final SnPublicationSite site;
final String pubName;
const PageItem({
super.key,
required this.page,
required this.site,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Card(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
elevation: 0,
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
leading: Icon(Symbols.article, color: theme.colorScheme.primary),
title: Text(page.path ?? '/'),
subtitle: Text(page.config?['title'] ?? 'Untitled'),
trailing: PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit'.tr()),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete'.tr()).textColor(Colors.red),
],
),
),
],
onSelected: (value) async {
switch (value) {
case 'edit':
// Open page edit dialog
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) =>
PageForm(site: site, pubName: pubName, page: page),
).then((_) {
// Refresh pages after editing
ref.invalidate(sitePagesProvider(pubName, site.slug));
});
break;
case 'delete':
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Delete Page'),
content: const Text(
'Are you sure you want to delete this page?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
await ref
.read(
sitePagesNotifierProvider((
pubName: pubName,
siteSlug: site.slug,
)).notifier,
)
.deletePage(page.id);
showSnackBar('Page deleted successfully');
} catch (e) {
showErrorAlert(e);
}
}
break;
}
},
),
onTap: () {
launchUrlString('https://${site.slug}.solian.page${page.path ?? ''}');
},
),
);
}
}

View File

@@ -0,0 +1,123 @@
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/pods/site_pages.dart';
import 'package:island/widgets/sites/page_form.dart';
import 'package:island/widgets/sites/page_item.dart';
import 'package:material_symbols_icons/symbols.dart';
class PagesSection extends HookConsumerWidget {
final SnPublicationSite site;
final String pubName;
const PagesSection({super.key, required this.site, required this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pagesAsync = ref.watch(sitePagesProvider(pubName, site.slug));
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Symbols.article, size: 20),
const Gap(8),
Text(
'sitePages'.tr(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
onPressed: () {
// Open page creation dialog
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => PageForm(site: site, pubName: pubName),
).then((_) {
// Refresh pages after creation
ref.invalidate(sitePagesProvider(pubName, site.slug));
});
},
icon: const Icon(Symbols.add),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
),
],
),
const Gap(16),
pagesAsync.when(
data: (pages) {
if (pages.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: [
Icon(
Symbols.article,
size: 48,
color: theme.colorScheme.outline,
),
const Gap(16),
Text(
'noPagesYet'.tr(),
style: theme.textTheme.bodyLarge,
),
const Gap(8),
Text(
'createFirstPage'.tr(),
style: theme.textTheme.bodySmall,
),
],
),
),
);
}
return ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: pages.length,
itemBuilder: (context, index) {
final page = pages[index];
return PageItem(page: page, site: site, pubName: pubName);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, stack) => Center(
child: Column(
children: [
Text('failedToLoadPages'.tr()),
const Gap(8),
ElevatedButton(
onPressed:
() => ref.invalidate(
sitePagesProvider(pubName, site.slug),
),
child: Text('retry'.tr()),
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,99 @@
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/pods/network.dart';
import 'package:island/screens/creators/sites/site_detail.dart';
import 'package:island/screens/creators/sites/site_edit.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class SiteActionMenu extends HookConsumerWidget {
final SnPublicationSite site;
final String pubName;
const SiteActionMenu({super.key, required this.site, required this.pubName});
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton<String>(
itemBuilder:
(context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(
Symbols.edit,
color: Theme.of(context).colorScheme.onSurface,
),
const Gap(16),
Text('edit'.tr()),
],
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete'.tr()).textColor(Colors.red),
],
),
),
],
onSelected: (value) async {
switch (value) {
case 'edit':
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) => SiteForm(pubName: pubName, siteSlug: site.slug),
).then((_) {
// Refresh site data after potential edit
ref.invalidate(publicationSiteDetailProvider(pubName, site.slug));
});
break;
case 'delete':
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('deleteSite'.tr()),
content: Text('publicationSiteDeleteConfirm'.tr()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('delete'.tr()),
),
],
),
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete('/zone/sites/$pubName/${site.slug}');
if (context.mounted) {
showSnackBar('siteDeletedSuccess'.tr());
Navigator.of(context).pop();
}
} catch (e) {
showErrorAlert(e);
}
}
break;
}
},
);
}
}

View File

@@ -0,0 +1,46 @@
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/site_info_card.dart';
import 'package:island/widgets/sites/pages_section.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/screens/creators/sites/site_detail.dart';
class SiteDetailContent extends HookConsumerWidget {
final SnPublicationSite site;
final String pubName;
const SiteDetailContent({
super.key,
required this.site,
required this.pubName,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ExtendedRefreshIndicator(
onRefresh:
() async =>
ref.invalidate(publicationSiteDetailProvider(pubName, site.slug)),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Site Info Card
SiteInfoCard(site: site),
const Gap(8),
if (site.mode == 1) // Self-Managed only
FileManagementActionSection(site: site, pubName: pubName),
// Pages Section
PagesSection(site: site, pubName: pubName),
FileManagementSection(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

@@ -381,6 +381,10 @@ class _EmbeddedPackSwitcherState extends State<_EmbeddedPackSwitcher> {
return Tooltip(
message: packs[i].name,
child: FilterChip(
visualDensity: const VisualDensity(
horizontal: 0,
vertical: -4,
),
label: Text(packs[i].name, overflow: TextOverflow.ellipsis),
selected: selected,
onSelected: (_) {

View File

@@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/widgets/alert.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -56,7 +57,7 @@ class FundEnvelopeWidget extends HookConsumerWidget {
),
const SizedBox(height: 8),
Text(
'Failed to load fund envelope',
'fundEnvelopeLoadFailed'.tr(),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
@@ -87,7 +88,7 @@ class FundEnvelopeWidget extends HookConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
'Fund Envelope',
'fundEnvelope'.tr(),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600),
),
@@ -115,7 +116,12 @@ class FundEnvelopeWidget extends HookConsumerWidget {
const SizedBox(height: 4),
if (fund.remainingAmount != fund.totalAmount)
Text(
'Remaining: ${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency}',
'fundEnvelopeRemaining'.tr(
args: [
fund.remainingAmount.toStringAsFixed(2),
fund.currency,
],
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
@@ -125,7 +131,13 @@ class FundEnvelopeWidget extends HookConsumerWidget {
),
),
Text(
'Split: ${fund.splitType == 0 ? 'Evenly' : 'Randomly'}',
'fundEnvelopeSplit'.tr(
args: [
fund.splitType == 0
? 'fundEnvelopeSplitEvenly'.tr()
: 'fundEnvelopeSplitRandomly'.tr(),
],
),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
@@ -244,20 +256,10 @@ class FundEnvelopeWidget extends HookConsumerWidget {
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(content: Text('Fund claimed successfully!'.tr())),
);
showSnackBar('fundEnvelopeClaimSuccess'.tr());
}
} catch (e) {
if (dialogContext.mounted) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
SnackBar(
content: Text('Failed to claim fund: $e'),
backgroundColor:
Theme.of(dialogContext).colorScheme.error,
),
);
}
showErrorAlert(e);
}
},
),
@@ -270,23 +272,23 @@ class FundEnvelopeWidget extends HookConsumerWidget {
switch (status) {
case 0:
text = 'Created';
text = 'fundEnvelopeStatusCreated'.tr();
color = Colors.blue;
break;
case 1:
text = 'Partially Claimed';
text = 'fundEnvelopeStatusPartial'.tr();
color = Colors.orange;
break;
case 2:
text = 'Fully Claimed';
text = 'fundEnvelopeStatusCompleted'.tr();
color = Colors.green;
break;
case 3:
text = 'Expired';
text = 'fundEnvelopeStatusExpired'.tr();
color = Colors.red;
break;
default:
text = 'Unknown';
text = 'fundEnvelopeStatusUnknown'.tr();
color = Colors.grey;
}
@@ -348,7 +350,9 @@ class FundEnvelopeWidget extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipients ($claimedCount/$totalCount claimed)',
'fundEnvelopeRecipients'.tr(
args: [claimedCount.toString(), totalCount.toString()],
),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@@ -371,15 +375,26 @@ class FundEnvelopeWidget extends HookConsumerWidget {
final difference = date.difference(now);
if (difference.isNegative) {
return 'Expired ${difference.inDays.abs()} days ago';
final days = difference.inDays.abs();
return 'fundEnvelopeExpiredDaysAgo'.plural(
days,
args: [days.toString()],
);
} else if (difference.inDays == 0) {
final hours = difference.inHours;
if (hours == 0) {
return 'Expires soon';
return 'fundEnvelopeExpiresSoon'.tr();
}
return 'Expires in $hours hour${hours == 1 ? '' : 's'}';
return 'fundEnvelopeExpiresInHours'.plural(
hours,
args: [hours.toString()],
);
} else if (difference.inDays < 7) {
return 'Expires in ${difference.inDays} day${difference.inDays == 1 ? '' : 's'}';
final days = difference.inDays;
return 'fundEnvelopeExpiresInDays'.plural(
days,
args: [days.toString()],
);
} else {
return '${date.day}/${date.month}/${date.year}';
}
@@ -458,7 +473,13 @@ class FundClaimDialog extends HookConsumerWidget {
// Remaining amount
Text(
'${fund.remainingAmount.toStringAsFixed(2)} ${fund.currency} / $remainingSplits splits',
'fundEnvelopeRemainingWithSplits'.tr(
args: [
fund.remainingAmount.toStringAsFixed(2),
fund.currency,
remainingSplits.toString(),
],
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.w500,
@@ -512,7 +533,8 @@ class FundClaimDialog extends HookConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
recipient.recipientAccount?.nick ?? 'Unknown User',
recipient.recipientAccount?.nick ??
'fundEnvelopeUnknownUser'.tr(),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
@@ -560,7 +582,8 @@ class FundClaimDialog extends HookConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
recipient.recipientAccount?.nick ?? 'Unknown User',
recipient.recipientAccount?.nick ??
'fundEnvelopeUnknownUser'.tr(),
style: Theme.of(context).textTheme.bodySmall,
),
),

View File

@@ -9,7 +9,6 @@
#include <desktop_drop/desktop_drop_plugin.h>
#include <file_saver/file_saver_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_platform_alert/flutter_platform_alert_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <flutter_timezone/flutter_timezone_plugin.h>
#include <flutter_udid/flutter_udid_plugin.h>
@@ -38,9 +37,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_platform_alert_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterPlatformAlertPlugin");
flutter_platform_alert_plugin_register_with_registrar(flutter_platform_alert_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

@@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
file_saver
file_selector_linux
flutter_platform_alert
flutter_secure_storage_linux
flutter_timezone
flutter_udid

View File

@@ -17,7 +17,6 @@ import firebase_crashlytics
import firebase_messaging
import flutter_inappwebview_macos
import flutter_local_notifications
import flutter_platform_alert
import flutter_secure_storage_macos
import flutter_timezone
import flutter_udid
@@ -30,7 +29,6 @@ import media_kit_libs_macos_video
import media_kit_video
import package_info_plus
import pasteboard
import path_provider_foundation
import protocol_handler_macos
import record_macos
import screen_retriever_macos
@@ -59,7 +57,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterPlatformAlertPlugin.register(with: registry.registrar(forPlugin: "FlutterPlatformAlertPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
FlutterUdidPlugin.register(with: registry.registrar(forPlugin: "FlutterUdidPlugin"))
@@ -72,7 +69,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ProtocolHandlerMacosPlugin.register(with: registry.registrar(forPlugin: "ProtocolHandlerMacosPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))

View File

@@ -102,8 +102,6 @@ PODS:
- OrderedSet (~> 6.0.3)
- flutter_local_notifications (0.0.1):
- FlutterMacOS
- flutter_platform_alert (0.0.1):
- FlutterMacOS
- flutter_secure_storage_macos (6.1.3):
- FlutterMacOS
- flutter_timezone (0.1.0):
@@ -189,14 +187,13 @@ PODS:
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- objective_c (0.0.1):
- FlutterMacOS
- OrderedSet (6.0.3)
- package_info_plus (0.0.1):
- FlutterMacOS
- pasteboard (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
@@ -269,7 +266,6 @@ DEPENDENCIES:
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- flutter_platform_alert (from `Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos`)
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`)
- flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`)
@@ -281,9 +277,9 @@ DEPENDENCIES:
- local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`)
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
- media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`)
- objective_c (from `Flutter/ephemeral/.symlinks/plugins/objective_c/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- protocol_handler_macos (from `Flutter/ephemeral/.symlinks/plugins/protocol_handler_macos/macos`)
- record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
@@ -349,8 +345,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
flutter_platform_alert:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos
flutter_secure_storage_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
flutter_timezone:
@@ -373,12 +367,12 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos
media_kit_video:
:path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos
objective_c:
:path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
pasteboard:
:path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
protocol_handler_macos:
:path: Flutter/ephemeral/.symlinks/plugins/protocol_handler_macos/macos
record_macos:
@@ -432,7 +426,6 @@ SPEC CHECKSUMS:
FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284
flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
flutter_timezone: d272288c69082ad571630e0d17140b3d6b93dc0c
flutter_udid: 00c09e022fd527fd39fef97670b220f2ae8190e7
@@ -449,10 +442,10 @@ SPEC CHECKSUMS:
media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
objective_c: ec13431e45ba099cb734eb2829a5c1cd37986cba
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
pasteboard: 278d8100149f940fb795d6b3a74f0720c890ecb7
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_macos: f9cd7b13bcaf6b0425f7410cbe52376cb843a936

View File

@@ -73,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
autotrie:
dependency: transitive
description:
name: autotrie
sha256: "55da6faefb53cfcb0abb2f2ca8636123fb40e35286bb57440d2cf467568188f8"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
avatar_stack:
dependency: "direct main"
description:
@@ -149,10 +157,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139"
url: "https://pub.dev"
source: hosted
version: "8.12.0"
version: "8.12.1"
cached_network_image:
dependency: "direct main"
description:
@@ -565,10 +573,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: f8f4ea435f791ab1f817b4e338ed958cb3d04ba43d6736ffc39958d950754967
sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200"
url: "https://pub.dev"
source: hosted
version: "10.3.6"
version: "10.3.7"
file_saver:
dependency: "direct main"
description:
@@ -581,18 +589,18 @@ packages:
dependency: transitive
description:
name: file_selector_linux
sha256: "80a877f5ec570c4fb3b40720a70b6f31e8bb1315a464b4d3e92fe82754d4b21a"
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.3+3"
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "44f24d102e368370951b98ffe86c7325b38349e634578312976607d28cc6d747"
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.4+6"
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
@@ -766,6 +774,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.2.0"
flutter_code_editor:
dependency: "direct main"
description:
name: flutter_code_editor
sha256: "9af48ba8e3558b6ea4bb98b84c5eb1649702acf53e61a84d88383eeb79b239b0"
url: "https://pub.dev"
source: hosted
version: "0.3.5"
flutter_colorpicker:
dependency: "direct main"
description:
@@ -1003,22 +1019,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.1+1"
flutter_platform_alert:
dependency: "direct main"
description:
name: flutter_platform_alert
sha256: "70f4979a617388cd890ec32e9acc1a6a425bcdf3d8b444eb976be1834e79dc0c"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687"
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.32"
version: "2.0.33"
flutter_popup_card:
dependency: "direct main"
description:
@@ -1103,10 +1111,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: "055de8921be7b8e8b98a233c7a5ef84b3a6fcc32f46f1ebf5b9bb3576d108355"
sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
version: "2.2.3"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -1253,6 +1261,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
hive:
dependency: transitive
description:
name: hive
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
hooks_riverpod:
dependency: "direct main"
description:
@@ -1329,10 +1345,10 @@ packages:
dependency: "direct main"
description:
name: image_picker_android
sha256: "788167bcb578b13613b17c3b4a4efae911715f353e36bddc48a0b02a54a8de40"
sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677"
url: "https://pub.dev"
source: hosted
version: "0.8.13+9"
version: "0.8.13+10"
image_picker_for_web:
dependency: transitive
description:
@@ -1461,6 +1477,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.2"
linked_scroll_controller:
dependency: transitive
description:
name: linked_scroll_controller
sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a
url: "https://pub.dev"
source: hosted
version: "0.2.0"
lint:
dependency: transitive
description:
@@ -1497,10 +1521,10 @@ packages:
dependency: transitive
description:
name: local_auth_android
sha256: a6a818a35ac5cae780d2e1b21391298dd749959715d6002f9730bd2de80e54a5
sha256: "04dd9050b59cb4bcaf051d44eec65865779a9b2f6daccc523f59f96b565a5d54"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "2.0.3"
local_auth_darwin:
dependency: transitive
description:
@@ -1685,6 +1709,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
mocktail:
dependency: transitive
description:
name: mocktail
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
modal_bottom_sheet:
dependency: "direct main"
description:
@@ -1725,6 +1757,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64"
url: "https://pub.dev"
source: hosted
version: "9.1.0"
octo_image:
dependency: transitive
description:
@@ -1793,18 +1833,18 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: "95c68a74d3cab950fd0ed8073d9fab15c1c06eb1f3eec68676e87aabc9ecee5a"
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.21"
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "97390a0719146c7c3e71b6866c34f1cde92685933165c1c671984390d2aca776"
sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba"
url: "https://pub.dev"
source: hosted
version: "2.4.4"
version: "2.5.0"
path_provider_linux:
dependency: transitive
description:
@@ -2066,18 +2106,18 @@ packages:
dependency: transitive
description:
name: record_android
sha256: fb54ee4e28f6829b8c580252a9ef49d9c549cfd263b0660ad7eeac0908658e9f
sha256: "9aaf3f151e61399b09bd7c31eb5f78253d2962b3f57af019ac5a2d1a3afdcf71"
url: "https://pub.dev"
source: hosted
version: "1.4.4"
version: "1.4.5"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: "765b42ac1be019b1674ddd809b811fc721fe5a93f7bb1da7803f0d16772fd6d7"
sha256: "69fcd37c6185834e90254573599a9165db18a2cbfa266b6d1e46ffffeb06a28c"
url: "https://pub.dev"
source: hosted
version: "1.1.4"
version: "1.1.5"
record_linux:
dependency: transitive
description:
@@ -2106,10 +2146,10 @@ packages:
dependency: transitive
description:
name: record_web
sha256: "20ac10d56514cb9f8cecc8f3579383084fdfb43b0d04e05a95244d0d76091d90"
sha256: "3feeffbc0913af3021da9810bb8702a068db6bc9da52dde1d19b6ee7cb9edb51"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
record_windows:
dependency: transitive
description:
@@ -2246,6 +2286,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
scrollable_positioned_list:
dependency: transitive
description:
name: scrollable_positioned_list
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
url: "https://pub.dev"
source: hosted
version: "0.3.8"
sdp_transform:
dependency: transitive
description:
@@ -2282,10 +2330,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: "07d552dbe8e71ed720e5205e760438ff4ecfb76ec3b32ea664350e2ca4b0c43b"
sha256: "46a46fd64659eff15f4638bbe19de43f9483f0e0bf024a9fb6b3582064bacc7b"
url: "https://pub.dev"
source: hosted
version: "2.4.16"
version: "2.4.17"
shared_preferences_foundation:
dependency: transitive
description:
@@ -2655,34 +2703,34 @@ packages:
dependency: "direct main"
description:
name: talker
sha256: "82de443cadfb6c41d457e7774c7890a91c73af3c2f17f3f7c01670bb58d5f5a1"
sha256: f17bde91ef86a51803974804bc6c0e161d14b496522bbc646c531be2c702e390
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.1.0"
talker_dio_logger:
dependency: "direct main"
description:
name: talker_dio_logger
sha256: "5bbecc237f3d2c4af9348da5a0086321ed6dd6bf9857d723b1f54f61c810cff2"
sha256: "322e8fee094d5fdce10f0439fec5625fb73fd282622b37c794bdaab4e1fc2b1b"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.1.0"
talker_flutter:
dependency: "direct main"
description:
name: talker_flutter
sha256: "4f7a8d739237a3a3c8ba4dddcdbc1f9d9dec143811641dbafebd6b70f947f8ca"
sha256: "5869c80c046a8e0478560fa2ebb40a3f0e025a9948bcdea50c12343cba681820"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.1.0"
talker_logger:
dependency: "direct main"
description:
name: talker_logger
sha256: "8218836d871ea5ab1ec616cffe3cdae84e8fb44022d5cc04c95d7b220572b8fb"
sha256: "87c66fefded42446d8c6365643582f3d180c0ce09485c129e9a88fa14d0d34eb"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.1.0"
talker_riverpod_logger:
dependency: "direct main"
description:
@@ -2800,10 +2848,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "1a109ee074e2a3b17ec3f2785248cb3e93c1e4abab878f637bc33267089f05a3"
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.26"
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 3.3.0+146
version: 3.4.0+149
environment:
sdk: ^3.7.2
@@ -61,7 +61,7 @@ dependencies:
media_kit_video: ^2.0.0
media_kit_libs_video: ^1.0.7
flutter_cache_manager: ^3.4.1
flutter_platform_alert: ^0.8.0
email_validator: ^3.0.0
easy_localization: ^3.0.8
flutter_inappwebview: ^6.1.5
@@ -73,10 +73,10 @@ dependencies:
git: https://github.com/LittleSheep2Code/tus_client.git
cross_file: ^0.3.5+1
image_picker: ^1.2.1
file_picker: ^10.3.6
file_picker: ^10.3.7
riverpod_annotation: ^2.6.1
image_picker_platform_interface: ^2.11.1
image_picker_android: ^0.8.13+9
image_picker_android: ^0.8.13+10
super_context_menu: ^0.9.1
modal_bottom_sheet: ^3.0.0
firebase_messaging: ^16.0.4
@@ -117,7 +117,7 @@ dependencies:
flutter_timezone: ^5.0.1
fl_chart: ^1.1.1
sign_in_with_apple: ^7.0.1
flutter_svg: ^2.2.2
flutter_svg: ^2.2.3
native_exif: ^0.6.2
local_auth: ^3.0.0
flutter_secure_storage: ^9.2.4
@@ -155,10 +155,10 @@ dependencies:
dart_ipc: ^1.0.1
pretty_diff_text: ^2.1.0
window_manager: ^0.5.1
talker: ^5.0.2
talker_flutter: ^5.0.2
talker_logger: ^5.0.2
talker_dio_logger: ^5.0.2
talker: ^5.1.0
talker_flutter: ^5.1.0
talker_logger: ^5.1.0
talker_dio_logger: ^5.1.0
talker_riverpod_logger: ^5.0.1
syncfusion_flutter_pdfviewer: ^31.1.21
swipe_to: ^1.0.6
@@ -170,6 +170,7 @@ dependencies:
desktop_drop: ^0.7.0
flutter_animate: ^4.5.2
http_parser: ^4.1.2
flutter_code_editor: ^0.3.5
dev_dependencies:
flutter_test:

View File

@@ -13,7 +13,6 @@
#include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_platform_alert/flutter_platform_alert_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_timezone/flutter_timezone_plugin_c_api.h>
#include <flutter_udid/flutter_udid_plugin_c_api.h>
@@ -52,8 +51,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
FlutterPlatformAlertPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterPlatformAlertPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterTimezonePluginCApiRegisterWithRegistrar(

View File

@@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_core
flutter_inappwebview_windows
flutter_platform_alert
flutter_secure_storage_windows
flutter_timezone
flutter_udid