Compare commits
35 Commits
d9af5d32fd
...
3.3.0+148
| Author | SHA1 | Date | |
|---|---|---|---|
|
5663df6ef1
|
|||
|
e996a0c95f
|
|||
|
a090e93f57
|
|||
|
c69034c071
|
|||
|
369ea6cf5b
|
|||
|
2e371b5296
|
|||
|
2e9d61bcfa
|
|||
|
9c2b5b0dfa
|
|||
|
3b40f515b3
|
|||
|
5ee61dbef2
|
|||
|
b151ef6686
|
|||
|
ff934d0f08
|
|||
|
abe5ded896
|
|||
|
f1d72a5215
|
|||
|
864cbe73b7
|
|||
|
108a6da074
|
|||
|
f9a09599c9
|
|||
|
9067dadd3e
|
|||
|
09f8df1e78
|
|||
|
2c5f246c55
|
|||
|
a66c6ea654
|
|||
|
3ad4bb4518
|
|||
|
53f0dcb825
|
|||
|
557f5a2389
|
|||
|
78f14f890f
|
|||
|
77b2effb34
|
|||
|
f02b4abf65
|
|||
|
3f37c4f761
|
|||
|
5deb910fa4
|
|||
|
f50a19f573
|
|||
|
98c8a356e8
|
|||
|
d0c16ea08f
|
|||
|
f2c1b2a531
|
|||
|
3061f0c5a9
|
|||
|
98f7f33c65
|
@@ -1340,5 +1340,136 @@
|
||||
"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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'call.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$callNotifierHash() => r'a8ca3f625c0db3ad9992033ae70864ce15efc281';
|
||||
String _$callNotifierHash() => r'ef4e3e9c9d411cf9dce1ceb456a3b866b2c87db3';
|
||||
|
||||
/// See also [CallNotifier].
|
||||
@ProviderFor(CallNotifier)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
@@ -6,6 +8,58 @@ import 'package:island/pods/chat/chat_subscribe.dart';
|
||||
|
||||
part 'chat_summary.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatUnreadCountNotifier extends _$ChatUnreadCountNotifier {
|
||||
StreamSubscription<WebSocketPacket>? _subscription;
|
||||
|
||||
@override
|
||||
Future<int> build() async {
|
||||
// Subscribe to websocket events when this provider is built
|
||||
_subscribeToWebSocket();
|
||||
|
||||
// Dispose the subscription when this provider is disposed
|
||||
ref.onDispose(() {
|
||||
_subscription?.cancel();
|
||||
});
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/sphere/chat/unread');
|
||||
return (response.data as num).toInt();
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToWebSocket() {
|
||||
final webSocketService = ref.read(websocketProvider);
|
||||
_subscription = webSocketService.dataStream.listen((packet) {
|
||||
if (packet.type == 'messages.new' && packet.data != null) {
|
||||
final message = SnChatMessage.fromJson(packet.data!);
|
||||
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
|
||||
// Only increment if the message is not from the currently subscribed chat
|
||||
if (message.chatRoomId != currentSubscribed) {
|
||||
_incrementCounter();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _incrementCounter() async {
|
||||
final current = await future;
|
||||
state = AsyncData(current + 1);
|
||||
}
|
||||
|
||||
Future<void> decrement(int count) async {
|
||||
final current = await future;
|
||||
state = AsyncData(math.max(current - count, 0));
|
||||
}
|
||||
|
||||
void clear() async {
|
||||
state = AsyncData(0);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
class ChatSummary extends _$ChatSummary {
|
||||
@override
|
||||
@@ -41,6 +95,14 @@ class ChatSummary extends _$ChatSummary {
|
||||
state.whenData((summaries) {
|
||||
final summary = summaries[chatId];
|
||||
if (summary != null) {
|
||||
// Decrement global unread count
|
||||
final unreadToDecrement = summary.unreadCount;
|
||||
if (unreadToDecrement > 0) {
|
||||
ref
|
||||
.read(chatUnreadCountNotifierProvider.notifier)
|
||||
.decrement(unreadToDecrement);
|
||||
}
|
||||
|
||||
state = AsyncData({
|
||||
...summaries,
|
||||
chatId: SnChatSummary(
|
||||
|
||||
@@ -6,6 +6,24 @@ part of 'chat_summary.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatUnreadCountNotifierHash() =>
|
||||
r'b8d93589dc37f772d4c3a07d9afd81c37026e57d';
|
||||
|
||||
/// See also [ChatUnreadCountNotifier].
|
||||
@ProviderFor(ChatUnreadCountNotifier)
|
||||
final chatUnreadCountNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<ChatUnreadCountNotifier, int>.internal(
|
||||
ChatUnreadCountNotifier.new,
|
||||
name: r'chatUnreadCountNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatUnreadCountNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ChatUnreadCountNotifier = AutoDisposeAsyncNotifier<int>;
|
||||
String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
|
||||
|
||||
/// See also [ChatSummary].
|
||||
|
||||
@@ -45,6 +45,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
bool _isSyncing = false;
|
||||
bool _isJumping = false;
|
||||
bool _isUpdatingState = false;
|
||||
bool _allRemoteMessagesFetched = false;
|
||||
DateTime? _lastPauseTime;
|
||||
|
||||
late final Future<SnAccount?> Function(String) _fetchAccount;
|
||||
@@ -278,6 +279,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
if (offset >= _totalCount!) {
|
||||
_allRemoteMessagesFetched = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -299,10 +301,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}).toList();
|
||||
|
||||
for (final message in messages) {
|
||||
await _database.saveMessage(_database.messageToCompanion(message));
|
||||
if (message.sender != null) {
|
||||
await _database.saveMember(message.sender!); // Save/update member data
|
||||
}
|
||||
await _database.saveMessageWithSender(message);
|
||||
if (message.nonce != null) {
|
||||
_pendingMessages.removeWhere(
|
||||
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
|
||||
@@ -310,6 +309,11 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've fetched all remote messages
|
||||
if (offset + messages.length >= _totalCount!) {
|
||||
_allRemoteMessagesFetched = true;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -319,6 +323,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return;
|
||||
}
|
||||
_isSyncing = true;
|
||||
_allRemoteMessagesFetched = false;
|
||||
|
||||
talker.log('Starting message sync');
|
||||
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
|
||||
@@ -346,19 +351,48 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final resp = await _apiClient.post(
|
||||
'/sphere/chat/${_room.id}/sync',
|
||||
data: {
|
||||
'last_sync_timestamp':
|
||||
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
|
||||
},
|
||||
);
|
||||
// Sync with pagination support using timestamp-based cursor
|
||||
int? totalMessages;
|
||||
int syncedCount = 0;
|
||||
int lastSyncTimestamp =
|
||||
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch;
|
||||
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
talker.log('Sync response: ${response.messages.length} changes');
|
||||
for (final message in response.messages) {
|
||||
await receiveMessage(message);
|
||||
}
|
||||
do {
|
||||
final resp = await _apiClient.post(
|
||||
'/sphere/chat/${_room.id}/sync',
|
||||
data: {'last_sync_timestamp': lastSyncTimestamp},
|
||||
);
|
||||
|
||||
// Read total count from header on first request
|
||||
if (totalMessages == null) {
|
||||
totalMessages = int.parse(
|
||||
resp.headers['x-total']?.firstOrNull ?? '0',
|
||||
);
|
||||
talker.log('Total messages to sync: $totalMessages');
|
||||
}
|
||||
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
final messagesCount = response.messages.length;
|
||||
talker.log(
|
||||
'Sync page: synced=$syncedCount/$totalMessages, count=$messagesCount',
|
||||
);
|
||||
|
||||
for (final message in response.messages) {
|
||||
await receiveMessage(message);
|
||||
}
|
||||
|
||||
syncedCount += messagesCount;
|
||||
|
||||
// Update cursor to the last message's createdAt for next page
|
||||
if (response.messages.isNotEmpty) {
|
||||
lastSyncTimestamp =
|
||||
response.messages.last.createdAt.millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
// Continue if there are more messages to fetch
|
||||
} while (syncedCount < totalMessages);
|
||||
|
||||
talker.log('Sync complete: synced $syncedCount messages');
|
||||
} catch (err, stackTrace) {
|
||||
talker.log(
|
||||
'Error syncing messages',
|
||||
@@ -397,14 +431,35 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
|
||||
if (localMessages.isNotEmpty) {
|
||||
// If we have local messages AND we've fetched all remote messages, return local
|
||||
if (localMessages.isNotEmpty && _allRemoteMessagesFetched) {
|
||||
return localMessages;
|
||||
}
|
||||
|
||||
// If we haven't fetched all remote messages, check remote even if we have local
|
||||
// OR if we have no local messages at all
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
return await _fetchAndCacheMessages(offset: offset, take: take);
|
||||
final remoteMessages = await _fetchAndCacheMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
);
|
||||
|
||||
// If we got remote messages, re-fetch from cache to get merged result
|
||||
if (remoteMessages.isNotEmpty) {
|
||||
return await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: take,
|
||||
searchQuery: _searchQuery,
|
||||
withLinks: _withLinks,
|
||||
withAttachments: _withAttachments,
|
||||
);
|
||||
}
|
||||
|
||||
// No remote messages, return local (if any)
|
||||
return localMessages;
|
||||
} else {
|
||||
return []; // If searching, and no local messages, don't fetch from network
|
||||
// For search queries, return local only
|
||||
return localMessages;
|
||||
}
|
||||
} catch (e) {
|
||||
final localMessages = await _getCachedMessages(
|
||||
@@ -424,6 +479,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
talker.log('Loading initial messages');
|
||||
_allRemoteMessagesFetched = false;
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
syncMessages();
|
||||
}
|
||||
@@ -445,6 +501,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
if (!_hasMore || state is AsyncLoading) return;
|
||||
talker.log('Loading more messages');
|
||||
|
||||
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
|
||||
try {
|
||||
final currentMessages = state.value ?? [];
|
||||
final offset = currentMessages.length;
|
||||
@@ -466,6 +523,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
Future.microtask(
|
||||
() => ref.read(isSyncingProvider.notifier).state = false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,8 +562,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages[localMessage.id] = localMessage;
|
||||
_fileUploadProgress[localMessage.id] = {};
|
||||
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||
await _database.saveMember(mockMessage.sender);
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
state = AsyncValue.data([localMessage, ...currentMessages]);
|
||||
@@ -553,7 +613,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages.remove(localMessage.id);
|
||||
await _database.deleteMessage(localMessage.id);
|
||||
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
if (editingTo != null) {
|
||||
@@ -635,7 +695,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
_pendingMessages.remove(pendingMessageId);
|
||||
await _database.deleteMessage(pendingMessageId);
|
||||
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
|
||||
await _database.saveMessageWithSender(updatedMessage);
|
||||
|
||||
final newMessages =
|
||||
(state.value ?? []).map((m) {
|
||||
@@ -692,7 +752,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(localMessage));
|
||||
await _database.saveMessageWithSender(localMessage);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
final existingIndex = currentMessages.indexWhere(
|
||||
@@ -789,7 +849,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
messageToUpdate.status,
|
||||
);
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(deletedMessage));
|
||||
await _database.saveMessageWithSender(deletedMessage);
|
||||
|
||||
if (messageIndex != -1) {
|
||||
final newList = [...currentMessages];
|
||||
@@ -913,6 +973,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_searchQuery = null;
|
||||
_withLinks = null;
|
||||
_withAttachments = null;
|
||||
_allRemoteMessagesFetched = false;
|
||||
loadInitial();
|
||||
}
|
||||
|
||||
@@ -938,7 +999,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
MessageStatus.sent,
|
||||
);
|
||||
|
||||
await _database.saveMessage(_database.messageToCompanion(message));
|
||||
await _database.saveMessageWithSender(message);
|
||||
return message;
|
||||
} catch (e) {
|
||||
if (e is DioException) return null;
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$messagesNotifierHash() => r'27e5d686d9204ba39adbd1838cf4a6eaea0ac85f';
|
||||
String _$messagesNotifierHash() => r'27ce32c54e317a04e1d554ed4a70a24e4503fdd1';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -6,11 +6,12 @@ import 'package:island/widgets/content/sheet.dart';
|
||||
|
||||
class CaptchaScreen extends ConsumerWidget {
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: false,
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
return Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// ignore_for_file: invalid_runtime_check_with_js_interop_types
|
||||
|
||||
import 'dart:ui_web' as ui;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
@@ -10,11 +8,12 @@ import 'package:flutter/material.dart';
|
||||
|
||||
class CaptchaScreen extends ConsumerStatefulWidget {
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isDismissible: false,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
return Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const CaptchaScreen(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +28,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
|
||||
|
||||
void _setupWebListener(String serverUrl) async {
|
||||
web.window.onMessage.listen((event) {
|
||||
// ignore: invalid_runtime_check_with_js_interop_types
|
||||
if (event.data != null && event.data is String) {
|
||||
// ignore: invalid_runtime_check_with_js_interop_types
|
||||
final message = event.data as String;
|
||||
if (message.startsWith("captcha_tk=")) {
|
||||
String token = message.replaceFirst("captcha_tk=", "");
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
169
lib/screens/chat/widgets/message_item_wrapper.dart
Normal file
169
lib/screens/chat/widgets/message_item_wrapper.dart
Normal 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: (_) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -846,7 +846,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 +1134,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 +1147,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -13,6 +14,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/widgets/sites/info_row.dart';
|
||||
import 'package:island/widgets/sites/pages_section.dart';
|
||||
import 'package:island/widgets/sites/file_management_section.dart';
|
||||
import 'package:island/widgets/sites/file_management_action_section.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
@@ -53,7 +55,7 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
|
||||
appBar: AppBar(
|
||||
title: siteAsync.maybeWhen(
|
||||
data: (site) => Text(site.name),
|
||||
orElse: () => const Text('Site Details'),
|
||||
orElse: () => Text('siteDetails'.tr()),
|
||||
),
|
||||
actions: [
|
||||
siteAsync.maybeWhen(
|
||||
@@ -94,76 +96,86 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
|
||||
flex: 2,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Site Information',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'siteInformation'.tr(),
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Gap(16),
|
||||
InfoRow(
|
||||
label: 'name'.tr(),
|
||||
value: site.name,
|
||||
icon: Symbols.title,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'slug'.tr(),
|
||||
value: site.slug,
|
||||
icon: Symbols.tag,
|
||||
monospace: true,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteDomain'.tr(),
|
||||
value: '${site.slug}.solian.page',
|
||||
icon: Symbols.globe,
|
||||
monospace: true,
|
||||
onTap: () {
|
||||
final url =
|
||||
'https://${site.slug}.solian.page';
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteMode'.tr(),
|
||||
value:
|
||||
site.mode == 0
|
||||
? 'siteModeFullyManaged'.tr()
|
||||
: 'siteModeSelfManaged'.tr(),
|
||||
icon: Symbols.settings,
|
||||
),
|
||||
if (site.description != null &&
|
||||
site.description!.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'description'.tr(),
|
||||
value: site.description!,
|
||||
icon: Symbols.description,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteCreated'.tr(),
|
||||
value: site.createdAt.formatSystem(),
|
||||
icon: Symbols.calendar_add_on,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'siteUpdated'.tr(),
|
||||
value: site.updatedAt.formatSystem(),
|
||||
icon: Symbols.update,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
InfoRow(
|
||||
label: 'Name',
|
||||
value: site.name,
|
||||
icon: Symbols.title,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Slug',
|
||||
value: site.slug,
|
||||
icon: Symbols.tag,
|
||||
monospace: true,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Domain',
|
||||
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: 'Mode',
|
||||
value:
|
||||
site.mode == 0
|
||||
? 'Fully Managed'
|
||||
: 'Self-Managed',
|
||||
icon: Symbols.settings,
|
||||
),
|
||||
if (site.description != null &&
|
||||
site.description!.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Description',
|
||||
value: site.description!,
|
||||
icon: Symbols.description,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Created',
|
||||
value: site.createdAt.formatSystem(),
|
||||
icon: Symbols.calendar_add_on,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Updated',
|
||||
value: site.updatedAt.formatSystem(),
|
||||
icon: Symbols.update,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
if (site.mode == 1) // Self-Managed only
|
||||
FileManagementActionSection(
|
||||
site: site,
|
||||
pubName: pubName,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -180,7 +192,7 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Failed to load site',
|
||||
'failedToLoadSite'.tr(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const Gap(16),
|
||||
@@ -191,7 +203,7 @@ class PublicationSiteDetailScreen extends HookConsumerWidget {
|
||||
() => ref.invalidate(
|
||||
publicationSiteDetailProvider(pubName, siteSlug),
|
||||
),
|
||||
child: const Text('Retry'),
|
||||
child: Text('retry'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,8 @@ 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(),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
@@ -199,7 +202,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 +246,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 +330,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 +354,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 +367,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),
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -73,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),
|
||||
@@ -200,21 +201,19 @@ class _CreatorSiteItem extends HookConsumerWidget {
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text('Delete Site'),
|
||||
content: Text(
|
||||
'Are you sure you want to delete this site?',
|
||||
),
|
||||
title: Text('deleteSite'.tr()),
|
||||
content: Text('deleteSiteConfirm'.tr()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
() =>
|
||||
Navigator.of(context).pop(false),
|
||||
child: Text('Cancel'),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() => Navigator.of(context).pop(true),
|
||||
child: Text('Delete'),
|
||||
child: Text('delete'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -224,21 +223,9 @@ class _CreatorSiteItem extends HookConsumerWidget {
|
||||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
showSnackBar('siteDeletedSuccess'.tr());
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to delete site'),
|
||||
),
|
||||
);
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -545,6 +545,7 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: FriendsOverviewWidget(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
hideWhenEmpty: true,
|
||||
),
|
||||
),
|
||||
if (notificationCount.value != null &&
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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: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 +30,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 +127,156 @@ 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();
|
||||
}
|
||||
|
||||
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();
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
globalOverlay.currentState!.insert(entry);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void showErrorAlert(dynamic err) {
|
||||
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) => AlertDialog(
|
||||
title: Text('somethingWentWrong'.tr()),
|
||||
content: Text(text),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(null),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showInfoAlert(String message, String title) {
|
||||
showOverlayDialog<void>(
|
||||
builder:
|
||||
(context, close) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(null),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> showConfirmAlert(String message, String title) async {
|
||||
final result = await showOverlayDialog<bool>(
|
||||
builder:
|
||||
(context, close) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => close(false),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => close(true),
|
||||
child: Text(MaterialLocalizations.of(context).okButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
80
lib/widgets/chat/call_content.dart
Normal file
80
lib/widgets/chat/call_content.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,6 @@ class MarkdownTextContent extends HookConsumerWidget {
|
||||
label: 'copyToClipboard'.tr(),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: href));
|
||||
clearSnackBar(context);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,18 +1,103 @@
|
||||
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});
|
||||
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) {
|
||||
@@ -74,16 +159,10 @@ class FileItem extends HookConsumerWidget {
|
||||
onSelected: (value) async {
|
||||
switch (value) {
|
||||
case 'download':
|
||||
// TODO: Implement file download
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Downloading ${file.relativePath}')),
|
||||
);
|
||||
await _downloadFile(context, ref);
|
||||
break;
|
||||
case 'edit':
|
||||
// TODO: Implement file editing
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Editing ${file.relativePath}')),
|
||||
);
|
||||
await _showEditSheet(context, ref);
|
||||
break;
|
||||
case 'delete':
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -128,20 +207,83 @@ class FileItem extends HookConsumerWidget {
|
||||
),
|
||||
onTap: () {
|
||||
if (file.isDirectory) {
|
||||
// TODO: Navigate into directory
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Opening directory: ${file.relativePath}'),
|
||||
),
|
||||
);
|
||||
onNavigateDirectory?.call(file.relativePath);
|
||||
} else {
|
||||
// TODO: Open file preview/editor
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Opening file: ${file.relativePath}')),
|
||||
);
|
||||
_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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
155
lib/widgets/sites/file_management_action_section.dart
Normal file
155
lib/widgets/sites/file_management_action_section.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
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 showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text('confirmPurge'.tr()),
|
||||
content: Text('purgeFilesConfirm'.tr()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text('purgeAllFiles'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
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()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
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';
|
||||
@@ -21,177 +24,266 @@ class FileManagementSection extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final filesAsync = ref.watch(siteFilesProvider(siteId: site.id));
|
||||
final currentPath = useState<String?>(null);
|
||||
final filesAsync = ref.watch(
|
||||
siteFilesProvider(siteId: site.id, path: currentPath.value),
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.folder, size: 20),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'File Management',
|
||||
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) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'No files found in the selected folder',
|
||||
),
|
||||
),
|
||||
);
|
||||
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),
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
itemBuilder:
|
||||
(BuildContext context) => [
|
||||
const PopupMenuItem<String>(
|
||||
value: 'files',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Symbols.file_copy),
|
||||
Gap(12),
|
||||
Text('Files'),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'folder',
|
||||
child: Row(
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Icon(Symbols.folder),
|
||||
Gap(12),
|
||||
Text('Folder'),
|
||||
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;
|
||||
}(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
style: ButtonStyle(
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
),
|
||||
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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
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(
|
||||
'No files uploaded yet',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'Upload your first file to get started',
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text('Failed to load files'),
|
||||
const Gap(8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
() => ref.invalidate(
|
||||
siteFilesProvider(siteId: site.id),
|
||||
),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -121,9 +122,7 @@ class FileUploadDialog extends HookConsumerWidget {
|
||||
(state) => state['status'] == 'completed',
|
||||
)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('All files uploaded successfully')),
|
||||
);
|
||||
showSnackBar('All files uploaded successfully');
|
||||
onUploadComplete();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -127,23 +128,15 @@ class PageForm extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
page == null
|
||||
? 'Page created successfully'
|
||||
: 'Page updated successfully',
|
||||
),
|
||||
),
|
||||
showSnackBar(
|
||||
page == null
|
||||
? 'Page created successfully'
|
||||
: 'Page updated successfully',
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to save page: ${e.toString()}')),
|
||||
);
|
||||
}
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -185,17 +178,11 @@ class PageForm extends HookConsumerWidget {
|
||||
await pagesNotifier.deletePage(page!.id);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Page deleted successfully')),
|
||||
);
|
||||
showSnackBar('Page deleted successfully');
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to delete page')),
|
||||
);
|
||||
}
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -15,6 +16,7 @@ class PageItem extends HookConsumerWidget {
|
||||
final String pubName;
|
||||
|
||||
const PageItem({
|
||||
super.key,
|
||||
required this.page,
|
||||
required this.site,
|
||||
required this.pubName,
|
||||
@@ -115,10 +117,7 @@ class PageItem extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
// TODO: Open page preview or edit
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Opening page: ${page.path ?? '/'}')),
|
||||
);
|
||||
launchUrlString('https://${site.slug}.solian.page${page.path ?? ''}');
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -29,7 +30,7 @@ class PagesSection extends HookConsumerWidget {
|
||||
const Icon(Symbols.article, size: 20),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'Pages',
|
||||
'sitePages'.tr(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -72,12 +73,12 @@ class PagesSection extends HookConsumerWidget {
|
||||
),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'No pages yet',
|
||||
'noPagesYet'.tr(),
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'Create your first page to get started',
|
||||
'createFirstPage'.tr(),
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -101,14 +102,14 @@ class PagesSection extends HookConsumerWidget {
|
||||
(error, stack) => Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text('Failed to load pages'),
|
||||
Text('failedToLoadPages'.tr()),
|
||||
const Gap(8),
|
||||
ElevatedButton(
|
||||
onPressed:
|
||||
() => ref.invalidate(
|
||||
sitePagesProvider(pubName, site.slug),
|
||||
),
|
||||
child: const Text('Retry'),
|
||||
child: Text('retry'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
@@ -63,18 +64,16 @@ class SiteActionMenu extends HookConsumerWidget {
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Delete Site'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete this publication site? This action cannot be undone.',
|
||||
),
|
||||
title: Text('deleteSite'.tr()),
|
||||
content: Text('publicationSiteDeleteConfirm'.tr()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Delete'),
|
||||
child: Text('delete'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -85,18 +84,11 @@ class SiteActionMenu extends HookConsumerWidget {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/zone/sites/${site.id}');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Site deleted successfully')),
|
||||
);
|
||||
// Navigate back to list
|
||||
showSnackBar('siteDeletedSuccess'.tr());
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to delete site')),
|
||||
);
|
||||
}
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/publication_site.dart';
|
||||
import 'package:island/widgets/sites/file_management_section.dart';
|
||||
import 'package:island/widgets/sites/file_management_action_section.dart';
|
||||
import 'package:island/widgets/sites/info_row.dart';
|
||||
import 'package:island/widgets/sites/pages_section.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
@@ -41,20 +43,20 @@ class SiteDetailContent extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Site Information',
|
||||
'siteInformation'.tr(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
InfoRow(
|
||||
label: 'Name',
|
||||
label: 'name'.tr(),
|
||||
value: site.name,
|
||||
icon: Symbols.title,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Slug',
|
||||
label: 'slug'.tr(),
|
||||
value: site.slug,
|
||||
icon: Symbols.tag,
|
||||
monospace: true,
|
||||
@@ -62,27 +64,30 @@ class SiteDetailContent extends HookConsumerWidget {
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Mode',
|
||||
value: site.mode == 0 ? 'Fully Managed' : 'Self-Managed',
|
||||
value:
|
||||
site.mode == 0
|
||||
? 'siteModeFullyManaged'.tr()
|
||||
: 'siteModeSelfManaged'.tr(),
|
||||
icon: Symbols.settings,
|
||||
),
|
||||
if (site.description != null &&
|
||||
site.description!.isNotEmpty) ...[
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Description',
|
||||
label: 'description'.tr(),
|
||||
value: site.description!,
|
||||
icon: Symbols.description,
|
||||
),
|
||||
],
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Created',
|
||||
label: 'siteCreated'.tr(),
|
||||
value: site.createdAt.formatSystem(),
|
||||
icon: Symbols.calendar_add_on,
|
||||
),
|
||||
const Gap(8),
|
||||
InfoRow(
|
||||
label: 'Updated',
|
||||
label: 'siteUpdated'.tr(),
|
||||
value: site.updatedAt.formatSystem(),
|
||||
icon: Symbols.update,
|
||||
),
|
||||
@@ -90,6 +95,9 @@ class SiteDetailContent extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
|
||||
@@ -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: (_) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
140
pubspec.lock
140
pubspec.lock
@@ -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:
|
||||
|
||||
19
pubspec.yaml
19
pubspec.yaml
@@ -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.3.0+148
|
||||
|
||||
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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user