Compare commits

...

15 Commits

Author SHA1 Message Date
LittleSheep
6892afb974 🔊 Add more logging and optimzation 2025-08-16 23:39:41 +08:00
LittleSheep
007b46b080 ♻️ Refactored the message repository logic 2025-08-16 23:07:21 +08:00
LittleSheep
67d130dc34 🔨 Sync the CMakeLists in linux to update date with the v2 modified version 2025-08-16 18:09:45 +08:00
LittleSheep
7e923c77fe 💄 Change explore screen wide mode breakpoint 2025-08-16 18:03:23 +08:00
LittleSheep
a593b52812 ♻️ Adjust the firebase analytics observer guard 2025-08-16 17:20:03 +08:00
LittleSheep
520dc80303 🔀 Merge pull request #169 from Texas0295/v3
🐛 linux: guard FirebaseAnalyticsObserver when Firebase is not initialized
2025-08-16 17:18:20 +08:00
LittleSheep
001897bbcd Notification indicator 2025-08-16 17:14:26 +08:00
Texas0295
bab29c23e3 🐛 linux: guard FirebaseAnalyticsObserver when Firebase is not initialized 2025-08-16 16:58:12 +08:00
LittleSheep
76b39f2df3 Mark all as read 2025-08-16 11:47:29 +08:00
LittleSheep
509b3e145b 🐛 Fix category selection render error 2025-08-16 02:39:00 +08:00
LittleSheep
2b80ebc2d0 🐛 Fix markdown image in chat close #167 2025-08-16 02:14:44 +08:00
LittleSheep
0ab908dd2a Auto collapse featured post if read 2025-08-16 02:02:06 +08:00
LittleSheep
6007467e7a 🐛 Fixes due to changes in backend 2025-08-15 03:33:20 +08:00
LittleSheep
3745157c42 💄 Optimize snackbars 2025-08-15 00:09:05 +08:00
LittleSheep
94481ec7bd 💫 Chnaged tab page animations 2025-08-14 14:21:57 +08:00
28 changed files with 2037 additions and 2002 deletions

View File

@@ -334,6 +334,7 @@
"walletCreate": "Create a Wallet",
"settingsServerUrl": "Server URL",
"settingsApplied": "The settings has been applied.",
"settingsCustomFontsHelper": "Use comma to seprate.",
"notifications": "Notifications",
"posts": "Posts",
"settingsBackgroundImage": "Background Image",

View File

@@ -300,6 +300,7 @@
"walletCreate": "创建钱包",
"settingsServerUrl": "服务器 URL",
"settingsApplied": "设置已应用。",
"settingsCustomFontsHelper": "用逗号分隔。",
"notifications": "通知",
"posts": "帖子",
"settingsBackgroundImage": "背景图片",

View File

@@ -245,7 +245,7 @@ PODS:
- PromisesObjC (= 2.4.0)
- receive_sharing_intent (1.8.1):
- Flutter
- record_ios (1.0.0):
- record_ios (1.1.0):
- Flutter
- SAMKeychain (1.5.3)
- SDWebImage (5.21.1):
@@ -510,7 +510,7 @@ SPEC CHECKSUMS:
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
record_ios: fee1c924aa4879b882ebca2b4bce6011bcfc3d8b
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: f29024626962457f3470184232766516dee8dfea
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a

View File

@@ -1,484 +0,0 @@
import 'package:dio/dio.dart';
import 'package:island/database/drift_db.dart';
import 'package:island/database/message.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/file.dart';
import 'package:island/services/file.dart';
import 'package:island/widgets/alert.dart';
import 'package:uuid/uuid.dart';
class MessageRepository {
final SnChatRoom room;
final SnChatMember identity;
final Dio _apiClient;
final AppDatabase _database;
final Map<String, LocalChatMessage> pendingMessages = {};
final Map<String, Map<int, double>> fileUploadProgress = {};
int? _totalCount;
MessageRepository(this.room, this.identity, this._apiClient, this._database);
Future<LocalChatMessage?> getLastMessages() async {
final dbMessages = await _database.getMessagesForRoom(
room.id,
offset: 0,
limit: 1,
);
if (dbMessages.isEmpty) {
return null;
}
return _database.companionToMessage(dbMessages.first);
}
Future<bool> syncMessages() async {
final lastMessage = await getLastMessages();
if (lastMessage == null) return false;
try {
final resp = await _apiClient.post(
'/sphere/chat/${room.id}/sync',
data: {
'last_sync_timestamp':
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
},
);
final response = MessageSyncResponse.fromJson(resp.data);
for (final change in response.changes) {
switch (change.action) {
case MessageChangeAction.create:
await receiveMessage(change.message!);
break;
case MessageChangeAction.update:
await receiveMessageUpdate(change.message!);
break;
case MessageChangeAction.delete:
await receiveMessageDeletion(change.messageId.toString());
break;
}
}
} catch (err) {
showErrorAlert(err);
}
return true;
}
Future<List<LocalChatMessage>> listMessages({
int offset = 0,
int take = 20,
bool synced = false,
}) async {
try {
// For initial load, fetch latest messages in the background to sync.
if (offset == 0 && !synced) {
// Not awaiting this is intentional, for a quicker UI response.
// The UI should rely on a stream from the database to get updates.
_fetchAndCacheMessages(room.id, offset: 0, take: take).catchError((_) {
// Best effort, errors will be handled by later fetches.
return <LocalChatMessage>[];
});
}
final localMessages = await _getCachedMessages(
room.id,
offset: offset,
take: take,
);
// If local cache has messages, return them. This is the common case for scrolling up.
if (localMessages.isNotEmpty) {
return localMessages;
}
// If local cache is empty, we've probably reached the end of cached history.
// Fetch from remote. This will also be hit on first load if cache is empty.
return await _fetchAndCacheMessages(room.id, offset: offset, take: take);
} catch (e) {
// Final fallback to cache in case of network errors during fetch.
final localMessages = await _getCachedMessages(
room.id,
offset: offset,
take: take,
);
if (localMessages.isNotEmpty) {
return localMessages;
}
rethrow;
}
}
Future<List<LocalChatMessage>> _getCachedMessages(
String roomId, {
int offset = 0,
int take = 20,
}) async {
// Get messages from local database
final dbMessages = await _database.getMessagesForRoom(
roomId,
offset: offset,
limit: take,
);
final dbLocalMessages =
dbMessages.map(_database.companionToMessage).toList();
// Combine with pending messages for the first page
if (offset == 0) {
final pendingForRoom =
pendingMessages.values.where((msg) => msg.roomId == roomId).toList();
final allMessages = [...pendingForRoom, ...dbLocalMessages];
allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
// Remove duplicates by ID, preserving the order
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in allMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
return uniqueMessages;
}
return dbLocalMessages;
}
Future<List<LocalChatMessage>> _fetchAndCacheMessages(
String roomId, {
int offset = 0,
int take = 20,
}) async {
// Use cached total count if available, otherwise fetch it
if (_totalCount == null) {
final response = await _apiClient.get(
'/sphere/chat/$roomId/messages',
queryParameters: {'offset': 0, 'take': 1},
);
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
}
if (offset >= _totalCount!) {
return [];
}
final response = await _apiClient.get(
'/sphere/chat/$roomId/messages',
queryParameters: {'offset': offset, 'take': take},
);
final List<dynamic> data = response.data;
// Update total count from response headers
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
final messages =
data.map((json) {
final remoteMessage = SnChatMessage.fromJson(json);
return LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
}).toList();
for (final message in messages) {
await _database.saveMessage(_database.messageToCompanion(message));
if (message.nonce != null) {
pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
);
}
}
return messages;
}
Future<LocalChatMessage> sendMessage(
String token,
String baseUrl,
String roomId,
String content,
String nonce, {
required List<UniversalFile> attachments,
Map<String, dynamic>? meta,
SnChatMessage? replyingTo,
SnChatMessage? forwardingTo,
SnChatMessage? editingTo,
Function(LocalChatMessage)? onPending,
Function(String, Map<int, double>)? onProgress,
}) async {
// Generate a unique nonce for this message
final nonce = const Uuid().v4();
// Create a local message with pending status
final mockMessage = SnChatMessage(
id: 'pending_$nonce',
chatRoomId: roomId,
senderId: identity.id,
content: content,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
nonce: nonce,
sender: identity,
);
final localMessage = LocalChatMessage.fromRemoteMessage(
mockMessage,
MessageStatus.pending,
);
// Store in memory and database
pendingMessages[localMessage.id] = localMessage;
fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage));
onPending?.call(localMessage);
try {
var cloudAttachments = List.empty(growable: true);
// Upload files
for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile =
await putMediaToCloud(
fileData: attachments[idx],
atk: token,
baseUrl: baseUrl,
filename: attachments[idx].data.name ?? 'Post media',
mimetype:
attachments[idx].data.mimeType ??
switch (attachments[idx].type) {
UniversalFileType.image => 'image/unknown',
UniversalFileType.video => 'video/unknown',
UniversalFileType.audio => 'audio/unknown',
UniversalFileType.file => 'application/octet-stream',
},
onProgress: (progress, _) {
fileUploadProgress[localMessage.id]?[idx] = progress;
onProgress?.call(
localMessage.id,
fileUploadProgress[localMessage.id] ?? {},
);
},
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
cloudAttachments.add(cloudFile);
}
// Send to server
final response = await _apiClient.request(
editingTo == null
? '/sphere/chat/$roomId/messages'
: '/sphere/chat/$roomId/messages/${editingTo.id}',
data: {
'content': content,
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
'replied_message_id': replyingTo?.id,
'forwarded_message_id': forwardingTo?.id,
'meta': meta,
'nonce': nonce,
},
options: Options(method: editingTo == null ? 'POST' : 'PATCH'),
);
// Update with server response
final remoteMessage = SnChatMessage.fromJson(response.data);
final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
// Remove from pending and update in database
pendingMessages.remove(localMessage.id);
await _database.deleteMessage(localMessage.id);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
return updatedMessage;
} catch (e) {
// Update status to failed
localMessage.status = MessageStatus.failed;
pendingMessages[localMessage.id] = localMessage;
await _database.updateMessageStatus(
localMessage.id,
MessageStatus.failed,
);
rethrow;
}
}
Future<LocalChatMessage> retryMessage(String pendingMessageId) async {
final message = await getMessageById(pendingMessageId);
if (message == null) {
throw Exception('Message not found');
}
// Update status back to pending
message.status = MessageStatus.pending;
pendingMessages[pendingMessageId] = message;
await _database.updateMessageStatus(
pendingMessageId,
MessageStatus.pending,
);
try {
// Send to server
var remoteMessage = message.toRemoteMessage();
final response = await _apiClient.post(
'/sphere/chat/${message.roomId}/messages',
data: {
'content': remoteMessage.content,
'attachments_id': remoteMessage.attachments,
'meta': remoteMessage.meta,
'nonce': message.nonce,
},
);
// Update with server response
remoteMessage = SnChatMessage.fromJson(response.data);
final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
// Remove from pending and update in database
pendingMessages.remove(pendingMessageId);
await _database.deleteMessage(pendingMessageId);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
return updatedMessage;
} catch (e) {
// Update status to failed
message.status = MessageStatus.failed;
pendingMessages[pendingMessageId] = message;
await _database.updateMessageStatus(
pendingMessageId,
MessageStatus.failed,
);
rethrow;
}
}
Future<LocalChatMessage> receiveMessage(SnChatMessage remoteMessage) async {
final localMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
if (remoteMessage.nonce != null) {
pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce,
);
}
await _database.saveMessage(_database.messageToCompanion(localMessage));
return localMessage;
}
Future<LocalChatMessage> receiveMessageUpdate(
SnChatMessage remoteMessage,
) async {
final localMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
await _database.updateMessage(_database.messageToCompanion(localMessage));
return localMessage;
}
Future<void> receiveMessageDeletion(String messageId) async {
// Remove from pending messages if exists
pendingMessages.remove(messageId);
// Delete from local database
await _database.deleteMessage(messageId);
}
Future<LocalChatMessage> updateMessage(
String messageId,
String content, {
List<SnCloudFile>? attachments,
Map<String, dynamic>? meta,
}) async {
final message = pendingMessages[messageId];
if (message != null) {
// Update pending message
final rmMessage = message.toRemoteMessage();
final updatedRemoteMessage = rmMessage.copyWith(
content: content,
meta: meta ?? rmMessage.meta,
);
final updatedLocalMessage = LocalChatMessage.fromRemoteMessage(
updatedRemoteMessage,
MessageStatus.pending,
);
pendingMessages[messageId] = updatedLocalMessage;
await _database.updateMessage(
_database.messageToCompanion(updatedLocalMessage),
);
return message;
}
try {
// Update on server
final response = await _apiClient.put(
'/sphere/chat/${room.id}/messages/$messageId',
data: {'content': content, 'attachments': attachments, 'meta': meta},
);
// Update local copy
final remoteMessage = SnChatMessage.fromJson(response.data);
final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
await _database.updateMessage(
_database.messageToCompanion(updatedMessage),
);
return updatedMessage;
} catch (e) {
rethrow;
}
}
Future<void> deleteMessage(String messageId) async {
try {
await _apiClient.delete('/sphere/chat/${room.id}/messages/$messageId');
pendingMessages.remove(messageId);
await _database.deleteMessage(messageId);
} catch (e) {
rethrow;
}
}
Future<LocalChatMessage?> getMessageById(String messageId) async {
try {
// Attempt to get the message from the local database
final localMessage =
await (_database.select(_database.chatMessages)
..where((tbl) => tbl.id.equals(messageId))).getSingleOrNull();
if (localMessage != null) {
return _database.companionToMessage(localMessage);
}
// If not found locally, fetch from the server
final response = await _apiClient.get(
'/sphere/chat/${room.id}/messages/$messageId',
);
final remoteMessage = SnChatMessage.fromJson(response.data);
final message = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
// Save the fetched message to the local database
await _database.saveMessage(_database.messageToCompanion(message));
return message;
} catch (e) {
if (e is DioException) return null;
// Handle errors
rethrow;
}
}
}

View File

@@ -11,8 +11,8 @@ sealed class SnScrappedLink with _$SnScrappedLink {
required String title,
required String? description,
required String? imageUrl,
required String faviconUrl,
required String siteName,
required String? faviconUrl,
required String? siteName,
required String? contentType,
required String? author,
required DateTime? publishedDate,

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnScrappedLink {
String get type; String get url; String get title; String? get description; String? get imageUrl; String get faviconUrl; String get siteName; String? get contentType; String? get author; DateTime? get publishedDate;
String get type; String get url; String get title; String? get description; String? get imageUrl; String? get faviconUrl; String? get siteName; String? get contentType; String? get author; DateTime? get publishedDate;
/// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -48,7 +48,7 @@ abstract mixin class $SnScrappedLinkCopyWith<$Res> {
factory $SnScrappedLinkCopyWith(SnScrappedLink value, $Res Function(SnScrappedLink) _then) = _$SnScrappedLinkCopyWithImpl;
@useResult
$Res call({
String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate
String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
});
@@ -65,16 +65,16 @@ class _$SnScrappedLinkCopyWithImpl<$Res>
/// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
return _then(_self.copyWith(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
as String?,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable
as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable
as DateTime?,
@@ -159,7 +159,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnScrappedLink() when $default != null:
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _:
@@ -180,7 +180,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate) $default,) {final _that = this;
switch (_that) {
case _SnScrappedLink():
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);}
@@ -197,7 +197,7 @@ return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUr
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate)? $default,) {final _that = this;
switch (_that) {
case _SnScrappedLink() when $default != null:
return $default(_that.type,_that.url,_that.title,_that.description,_that.imageUrl,_that.faviconUrl,_that.siteName,_that.contentType,_that.author,_that.publishedDate);case _:
@@ -220,8 +220,8 @@ class _SnScrappedLink implements SnScrappedLink {
@override final String title;
@override final String? description;
@override final String? imageUrl;
@override final String faviconUrl;
@override final String siteName;
@override final String? faviconUrl;
@override final String? siteName;
@override final String? contentType;
@override final String? author;
@override final DateTime? publishedDate;
@@ -259,7 +259,7 @@ abstract mixin class _$SnScrappedLinkCopyWith<$Res> implements $SnScrappedLinkCo
factory _$SnScrappedLinkCopyWith(_SnScrappedLink value, $Res Function(_SnScrappedLink) _then) = __$SnScrappedLinkCopyWithImpl;
@override @useResult
$Res call({
String type, String url, String title, String? description, String? imageUrl, String faviconUrl, String siteName, String? contentType, String? author, DateTime? publishedDate
String type, String url, String title, String? description, String? imageUrl, String? faviconUrl, String? siteName, String? contentType, String? author, DateTime? publishedDate
});
@@ -276,16 +276,16 @@ class __$SnScrappedLinkCopyWithImpl<$Res>
/// Create a copy of SnScrappedLink
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = null,Object? siteName = null,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? url = null,Object? title = null,Object? description = freezed,Object? imageUrl = freezed,Object? faviconUrl = freezed,Object? siteName = freezed,Object? contentType = freezed,Object? author = freezed,Object? publishedDate = freezed,}) {
return _then(_SnScrappedLink(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable
as String?,faviconUrl: null == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
as String,siteName: null == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
as String,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String?,faviconUrl: freezed == faviconUrl ? _self.faviconUrl : faviconUrl // ignore: cast_nullable_to_non_nullable
as String?,siteName: freezed == siteName ? _self.siteName : siteName // ignore: cast_nullable_to_non_nullable
as String?,contentType: freezed == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
as String?,author: freezed == author ? _self.author : author // ignore: cast_nullable_to_non_nullable
as String?,publishedDate: freezed == publishedDate ? _self.publishedDate : publishedDate // ignore: cast_nullable_to_non_nullable
as DateTime?,

View File

@@ -13,8 +13,8 @@ _SnScrappedLink _$SnScrappedLinkFromJson(Map<String, dynamic> json) =>
title: json['title'] as String,
description: json['description'] as String?,
imageUrl: json['image_url'] as String?,
faviconUrl: json['favicon_url'] as String,
siteName: json['site_name'] as String,
faviconUrl: json['favicon_url'] as String?,
siteName: json['site_name'] as String?,
contentType: json['content_type'] as String?,
author: json['author'] as String?,
publishedDate:

View File

@@ -23,6 +23,8 @@ const kAppSoundEffects = 'app_sound_effects';
const kAppAprilFoolFeatures = 'app_april_fool_features';
const kAppWindowSize = 'app_window_size';
const kAppEnterToSend = 'app_enter_to_send';
const kFeaturedPostsCollapsedId =
'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post
const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none,

View File

@@ -1,6 +1,8 @@
import 'dart:io' show Platform;
import 'package:animations/animations.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/screens/about.dart';
@@ -56,12 +58,34 @@ final rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();
final _tabsShellKey = GlobalKey<NavigatorState>();
Widget _tabPagesTransitionBuilder(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeThroughTransition(
animation: animation,
secondaryAnimation: secondaryAnimation,
fillColor: Theme.of(context).colorScheme.surface,
child: child,
);
}
bool get _supportsAnalytics =>
kIsWeb ||
Platform.isAndroid ||
Platform.isIOS ||
Platform.isMacOS ||
Platform.isWindows;
// Provider for the router
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
navigatorKey: rootNavigatorKey,
initialLocation: '/',
observers: [
if (_supportsAnalytics)
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance),
],
routes: [
@@ -339,7 +363,12 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
name: 'explore',
path: '/',
builder: (context, state) => const ExploreScreen(),
pageBuilder:
(context, state) => CustomTransitionPage(
key: const ValueKey('explore'),
child: const ExploreScreen(),
transitionsBuilder: _tabPagesTransitionBuilder,
),
),
GoRoute(
name: 'postSearch',
@@ -389,8 +418,12 @@ final routerProvider = Provider<GoRouter>((ref) {
// Chat tab
ShellRoute(
builder:
(context, state, child) => ChatShellScreen(child: child),
pageBuilder:
(context, state, child) => CustomTransitionPage(
key: const ValueKey('chat'),
child: ChatShellScreen(child: child),
transitionsBuilder: _tabPagesTransitionBuilder,
),
routes: [
GoRoute(
name: 'chatList',
@@ -433,7 +466,12 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
name: 'realmList',
path: '/realms',
builder: (context, state) => const RealmListScreen(),
pageBuilder:
(context, state) => CustomTransitionPage(
key: const ValueKey('realms'),
child: const RealmListScreen(),
transitionsBuilder: _tabPagesTransitionBuilder,
),
routes: [
GoRoute(
name: 'realmNew',
@@ -461,8 +499,12 @@ final routerProvider = Provider<GoRouter>((ref) {
// Account tab
ShellRoute(
builder:
(context, state, child) => AccountShellScreen(child: child),
pageBuilder:
(context, state, child) => CustomTransitionPage(
key: const ValueKey('account'),
child: AccountShellScreen(child: child),
transitionsBuilder: _tabPagesTransitionBuilder,
),
routes: [
GoRoute(
name: 'account',

View File

@@ -178,7 +178,8 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
context,
icon: Symbols.label,
label: 'aboutDeviceName'.tr(),
value: _deviceInfo?.data['name'],
value:
_deviceInfo?.data['name'] ?? 'unknown'.tr(),
),
_buildInfoItem(
context,

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -10,14 +12,15 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/database/drift_db.dart';
import 'package:island/database/message.dart';
import 'package:island/database/message_repository.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/database.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
@@ -39,17 +42,46 @@ import 'package:island/widgets/stickers/picker.dart';
part 'room.g.dart';
final messageRepositoryProvider =
FutureProvider.family<MessageRepository, String>((ref, roomId) async {
final room = await ref.watch(chatroomProvider(roomId).future);
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
final apiClient = ref.watch(apiClientProvider);
final database = ref.watch(databaseProvider);
return MessageRepository(room!, identity!, apiClient, database);
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
final controller = StreamController<AppLifecycleState>();
final observer = _AppLifecycleObserver((state) {
if (controller.isClosed) return;
controller.add(state);
});
WidgetsBinding.instance.addObserver(observer);
ref.onDispose(() {
WidgetsBinding.instance.removeObserver(observer);
controller.close();
});
return controller.stream;
});
class _AppLifecycleObserver extends WidgetsBindingObserver {
final ValueChanged<AppLifecycleState> onChange;
_AppLifecycleObserver(this.onChange);
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
onChange(state);
}
}
@riverpod
class MessagesNotifier extends _$MessagesNotifier {
late final Dio _apiClient;
late final AppDatabase _database;
late final SnChatRoom _room;
late final SnChatMember _identity;
final Map<String, LocalChatMessage> _pendingMessages = {};
final Map<String, Map<int, double>> _fileUploadProgress = {};
int? _totalCount;
late final String _roomId;
int _currentPage = 0;
static const int _pageSize = 20;
@@ -58,38 +90,209 @@ class MessagesNotifier extends _$MessagesNotifier {
@override
FutureOr<List<LocalChatMessage>> build(String roomId) async {
_roomId = roomId;
_apiClient = ref.watch(apiClientProvider);
_database = ref.watch(databaseProvider);
final room = await ref.watch(chatroomProvider(roomId).future);
final identity = await ref.watch(chatroomIdentityProvider(roomId).future);
if (room == null || identity == null) {
throw Exception('Room or identity not found');
}
_room = room;
_identity = identity;
developer.log('MessagesNotifier built for room $roomId', name: 'MessagesNotifier');
ref.listen(appLifecycleStateProvider, (_, next) {
if (next.hasValue && next.value == AppLifecycleState.resumed) {
developer.log('App resumed, syncing messages', name: 'MessagesNotifier');
syncMessages();
}
});
return await loadInitial();
}
Future<List<LocalChatMessage>> loadInitial() async {
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
Future<List<LocalChatMessage>> _getCachedMessages({
int offset = 0,
int take = 20,
}) async {
developer.log('Getting cached messages from offset $offset, take $take', name: 'MessagesNotifier');
final dbMessages = await _database.getMessagesForRoom(
_roomId,
offset: offset,
limit: take,
);
final synced = await repository.syncMessages();
final messages = await repository.listMessages(
offset: 0,
take: _pageSize,
synced: synced,
final dbLocalMessages =
dbMessages.map(_database.companionToMessage).toList();
if (offset == 0) {
final pendingForRoom =
_pendingMessages.values.where((msg) => msg.roomId == _roomId).toList();
final allMessages = [...pendingForRoom, ...dbLocalMessages];
allMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt));
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in allMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
return uniqueMessages;
}
return dbLocalMessages;
}
Future<List<LocalChatMessage>> _fetchAndCacheMessages({
int offset = 0,
int take = 20,
}) async {
developer.log('Fetching messages from API, offset $offset, take $take', name: 'MessagesNotifier');
if (_totalCount == null) {
final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages',
queryParameters: {'offset': 0, 'take': 1},
);
_currentPage = 0;
_hasMore = messages.length == _pageSize;
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
}
if (offset >= _totalCount!) {
return [];
}
final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages',
queryParameters: {'offset': offset, 'take': take},
);
final List<dynamic> data = response.data;
_totalCount = int.parse(response.headers['x-total']?.firstOrNull ?? '0');
final messages = data.map((json) {
final remoteMessage = SnChatMessage.fromJson(json);
return LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
}).toList();
for (final message in messages) {
await _database.saveMessage(_database.messageToCompanion(message));
if (message.nonce != null) {
_pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == message.nonce,
);
}
}
return messages;
} catch (_) {
}
Future<void> syncMessages() async {
developer.log('Starting message sync', name: 'MessagesNotifier');
ref.read(isSyncingProvider.notifier).state = true;
try {
final dbMessages = await _database.getMessagesForRoom(
_room.id,
offset: 0,
limit: 1,
);
final lastMessage =
dbMessages.isEmpty ? null : _database.companionToMessage(dbMessages.first);
if (lastMessage == null) {
developer.log('No local messages, fetching from network', name: 'MessagesNotifier');
final newMessages = await _fetchAndCacheMessages(offset: 0, take: _pageSize);
state = AsyncValue.data(newMessages);
return;
}
final resp = await _apiClient.post(
'/sphere/chat/${_room.id}/sync',
data: {
'last_sync_timestamp':
lastMessage.toRemoteMessage().updatedAt.millisecondsSinceEpoch,
},
);
final response = MessageSyncResponse.fromJson(resp.data);
developer.log('Sync response: ${response.changes.length} changes', name: 'MessagesNotifier');
for (final change in response.changes) {
switch (change.action) {
case MessageChangeAction.create:
await receiveMessage(change.message!);
break;
case MessageChangeAction.update:
await receiveMessageUpdate(change.message!);
break;
case MessageChangeAction.delete:
await receiveMessageDeletion(change.messageId.toString());
break;
}
}
} catch (err, stackTrace) {
developer.log('Error syncing messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace);
showErrorAlert(err);
} finally {
developer.log('Finished message sync', name: 'MessagesNotifier');
ref.read(isSyncingProvider.notifier).state = false;
}
}
Future<List<LocalChatMessage>> listMessages({
int offset = 0,
int take = 20,
bool synced = false,
}) async {
try {
if (offset == 0 && !synced) {
_fetchAndCacheMessages(offset: 0, take: take).catchError((_) {
return <LocalChatMessage>[];
});
}
final localMessages = await _getCachedMessages(
offset: offset,
take: take,
);
if (localMessages.isNotEmpty) {
return localMessages;
}
return await _fetchAndCacheMessages(offset: offset, take: take);
} catch (e) {
final localMessages = await _getCachedMessages(
offset: offset,
take: take,
);
if (localMessages.isNotEmpty) {
return localMessages;
}
rethrow;
}
}
Future<List<LocalChatMessage>> loadInitial() async {
developer.log('Loading initial messages', name: 'MessagesNotifier');
syncMessages();
final messages = await _getCachedMessages(offset: 0, take: _pageSize);
_currentPage = 0;
_hasMore = messages.length == _pageSize;
return messages;
}
Future<void> loadMore() async {
if (!_hasMore || state is AsyncLoading) return;
developer.log('Loading more messages', name: 'MessagesNotifier');
try {
final currentMessages = state.value ?? [];
_currentPage++;
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
final newMessages = await repository.listMessages(
final newMessages = await listMessages(
offset: _currentPage * _pageSize,
take: _pageSize,
);
@@ -99,7 +302,8 @@ class MessagesNotifier extends _$MessagesNotifier {
}
state = AsyncValue.data([...currentMessages, ...newMessages]);
} catch (err) {
} catch (err, stackTrace) {
developer.log('Error loading more messages', name: 'MessagesNotifier', error: err, stackTrace: stackTrace);
showErrorAlert(err);
_currentPage--;
}
@@ -113,77 +317,196 @@ class MessagesNotifier extends _$MessagesNotifier {
SnChatMessage? replyingTo,
Function(String, Map<int, double>)? onProgress,
}) async {
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
final nonce = const Uuid().v4();
developer.log('Sending message with nonce $nonce', name: 'MessagesNotifier');
final baseUrl = ref.read(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Access token is null');
final currentMessages = state.value ?? [];
await repository.sendMessage(
token,
baseUrl,
_roomId,
content,
const Uuid().v4(),
attachments: attachments,
editingTo: editingTo,
forwardingTo: forwardingTo,
replyingTo: replyingTo,
onPending: (pending) {
state = AsyncValue.data([pending, ...currentMessages]);
},
onProgress: onProgress,
final mockMessage = SnChatMessage(
id: 'pending_$nonce',
chatRoomId: _roomId,
senderId: _identity.id,
content: content,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
nonce: nonce,
sender: _identity,
);
// Refresh messages
final messages = await repository.listMessages(
offset: 0,
take: _pageSize,
final localMessage = LocalChatMessage.fromRemoteMessage(
mockMessage,
MessageStatus.pending,
);
state = AsyncValue.data(messages);
} catch (err) {
showErrorAlert(err);
_pendingMessages[localMessage.id] = localMessage;
_fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage));
final currentMessages = state.value ?? [];
state = AsyncValue.data([localMessage, ...currentMessages]);
try {
var cloudAttachments = List.empty(growable: true);
for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile = await putMediaToCloud(
fileData: attachments[idx],
atk: token,
baseUrl: baseUrl,
filename: attachments[idx].data.name ?? 'Post media',
mimetype: attachments[idx].data.mimeType ??
switch (attachments[idx].type) {
UniversalFileType.image => 'image/unknown',
UniversalFileType.video => 'video/unknown',
UniversalFileType.audio => 'audio/unknown',
UniversalFileType.file => 'application/octet-stream',
},
onProgress: (progress, _) {
_fileUploadProgress[localMessage.id]?[idx] = progress;
onProgress?.call(
localMessage.id,
_fileUploadProgress[localMessage.id] ?? {},
);
},
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
cloudAttachments.add(cloudFile);
}
final response = await _apiClient.request(
editingTo == null
? '/sphere/chat/$_roomId/messages'
: '/sphere/chat/$_roomId/messages/${editingTo.id}',
data: {
'content': content,
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
'replied_message_id': replyingTo?.id,
'forwarded_message_id': forwardingTo?.id,
'meta': {},
'nonce': nonce,
},
options: Options(method: editingTo == null ? 'POST' : 'PATCH'),
);
final remoteMessage = SnChatMessage.fromJson(response.data);
final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
_pendingMessages.remove(localMessage.id);
await _database.deleteMessage(localMessage.id);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
final newMessages = (state.value ?? []).map((m) {
if (m.id == localMessage.id) {
return updatedMessage;
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
developer.log('Message with nonce $nonce sent successfully', name: 'MessagesNotifier');
} catch (e, stackTrace) {
developer.log('Failed to send message with nonce $nonce', name: 'MessagesNotifier', error: e, stackTrace: stackTrace);
localMessage.status = MessageStatus.failed;
_pendingMessages[localMessage.id] = localMessage;
await _database.updateMessageStatus(
localMessage.id,
MessageStatus.failed,
);
final newMessages = (state.value ?? []).map((m) {
if (m.id == localMessage.id) {
return m..status = MessageStatus.failed;
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
showErrorAlert(e);
}
}
Future<void> retryMessage(String pendingMessageId) async {
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
final updatedMessage = await repository.retryMessage(pendingMessageId);
// Update the message in the list
final currentMessages = state.value ?? [];
final index = currentMessages.indexWhere((m) => m.id == pendingMessageId);
if (index >= 0) {
final newList = [...currentMessages];
newList[index] = updatedMessage;
state = AsyncValue.data(newList);
developer.log('Retrying message $pendingMessageId', name: 'MessagesNotifier');
final message = await fetchMessageById(pendingMessageId);
if (message == null) {
throw Exception('Message not found');
}
} catch (err) {
showErrorAlert(err);
message.status = MessageStatus.pending;
_pendingMessages[pendingMessageId] = message;
await _database.updateMessageStatus(
pendingMessageId,
MessageStatus.pending,
);
try {
var remoteMessage = message.toRemoteMessage();
final response = await _apiClient.post(
'/sphere/chat/${message.roomId}/messages',
data: {
'content': remoteMessage.content,
'attachments_id': remoteMessage.attachments,
'meta': remoteMessage.meta,
'nonce': message.nonce,
},
);
remoteMessage = SnChatMessage.fromJson(response.data);
final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
_pendingMessages.remove(pendingMessageId);
await _database.deleteMessage(pendingMessageId);
await _database.saveMessage(_database.messageToCompanion(updatedMessage));
final newMessages = (state.value ?? []).map((m) {
if (m.id == pendingMessageId) {
return updatedMessage;
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
} catch (e, stackTrace) {
developer.log('Failed to retry message $pendingMessageId', name: 'MessagesNotifier', error: e, stackTrace: stackTrace);
message.status = MessageStatus.failed;
_pendingMessages[pendingMessageId] = message;
await _database.updateMessageStatus(
pendingMessageId,
MessageStatus.failed,
);
final newMessages = (state.value ?? []).map((m) {
if (m.id == pendingMessageId) {
return m..status = MessageStatus.failed;
}
return m;
}).toList();
state = AsyncValue.data(newMessages);
showErrorAlert(e);
}
}
Future<void> receiveMessage(SnChatMessage remoteMessage) async {
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
if (remoteMessage.chatRoomId != _roomId) return;
developer.log('Received new message ${remoteMessage.id}', name: 'MessagesNotifier');
final localMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
// Skip if this message is not for this room
if (remoteMessage.chatRoomId != _roomId) return;
if (remoteMessage.nonce != null) {
_pendingMessages.removeWhere(
(_, pendingMsg) => pendingMsg.nonce == remoteMessage.nonce,
);
}
final localMessage = await repository.receiveMessage(remoteMessage);
await _database.saveMessage(_database.messageToCompanion(localMessage));
// Add the new message to the state
final currentMessages = state.value ?? [];
// Check if the message already exists (by id or nonce)
final existingIndex = currentMessages.indexWhere(
(m) =>
m.id == localMessage.id ||
@@ -191,33 +514,24 @@ class MessagesNotifier extends _$MessagesNotifier {
);
if (existingIndex >= 0) {
// Replace existing message
final newList = [...currentMessages];
newList[existingIndex] = localMessage;
state = AsyncValue.data(newList);
} else {
// Add new message at the beginning (newest first)
state = AsyncValue.data([localMessage, ...currentMessages]);
}
} catch (err) {
showErrorAlert(err);
}
}
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
// Skip if this message is not for this room
if (remoteMessage.chatRoomId != _roomId) return;
developer.log('Received message update ${remoteMessage.id}', name: 'MessagesNotifier');
final updatedMessage = await repository.receiveMessageUpdate(
final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
// Update the message in the list
final currentMessages = state.value ?? [];
final index = currentMessages.indexWhere(
(m) => m.id == updatedMessage.id,
@@ -228,20 +542,13 @@ class MessagesNotifier extends _$MessagesNotifier {
newList[index] = updatedMessage;
state = AsyncValue.data(newList);
}
} catch (err) {
showErrorAlert(err);
}
}
Future<void> receiveMessageDeletion(String messageId) async {
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
developer.log('Received message deletion $messageId', name: 'MessagesNotifier');
_pendingMessages.remove(messageId);
await _database.deleteMessage(messageId);
await repository.receiveMessageDeletion(messageId);
// Remove the message from the list
final currentMessages = state.value ?? [];
final filteredMessages =
currentMessages.where((m) => m.id != messageId).toList();
@@ -249,41 +556,43 @@ class MessagesNotifier extends _$MessagesNotifier {
if (filteredMessages.length != currentMessages.length) {
state = AsyncValue.data(filteredMessages);
}
} catch (err) {
showErrorAlert(err);
}
}
Future<void> deleteMessage(String messageId) async {
developer.log('Deleting message $messageId', name: 'MessagesNotifier');
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
await repository.deleteMessage(messageId);
// Remove the message from the list
final currentMessages = state.value ?? [];
final filteredMessages =
currentMessages.where((m) => m.id != messageId).toList();
if (filteredMessages.length != currentMessages.length) {
state = AsyncValue.data(filteredMessages);
}
} catch (err) {
await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
await receiveMessageDeletion(messageId);
} catch (err, stackTrace) {
developer.log('Error deleting message $messageId', name: 'MessagesNotifier', error: err, stackTrace: stackTrace);
showErrorAlert(err);
}
}
Future<LocalChatMessage?> fetchMessageById(String messageId) async {
developer.log('Fetching message by id $messageId', name: 'MessagesNotifier');
try {
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
final localMessage = await (_database.select(_database.chatMessages)
..where((tbl) => tbl.id.equals(messageId)))
.getSingleOrNull();
if (localMessage != null) {
return _database.companionToMessage(localMessage);
}
final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages/$messageId',
);
return await repository.getMessageById(messageId);
} catch (err) {
showErrorAlert(err);
return null;
final remoteMessage = SnChatMessage.fromJson(response.data);
final message = LocalChatMessage.fromRemoteMessage(
remoteMessage,
MessageStatus.sent,
);
await _database.saveMessage(_database.messageToCompanion(message));
return message;
} catch (e) {
if (e is DioException) return null;
rethrow;
}
}
}
@@ -296,6 +605,7 @@ class ChatRoomScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final chatRoom = ref.watch(chatroomProvider(id));
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
final isSyncing = ref.watch(isSyncingProvider);
if (chatIdentity.isLoading || chatRoom.isLoading) {
return AppScaffold(
@@ -307,8 +617,7 @@ class ChatRoomScreen extends HookConsumerWidget {
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: Center(
child:
ConstrainedBox(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -417,10 +726,8 @@ class ChatRoomScreen extends HookConsumerWidget {
if (typingStatuses.value.isNotEmpty) {
// Remove typing statuses older than 5 seconds
final now = DateTime.now();
typingStatuses.value =
typingStatuses.value.where((member) {
final lastTyped =
member.lastTyped ??
typingStatuses.value = typingStatuses.value.where((member) {
final lastTyped = member.lastTyped ??
DateTime.now().subtract(const Duration(milliseconds: 1350));
return now.difference(lastTyped).inSeconds < 5;
}).toList();
@@ -594,9 +901,7 @@ class ChatRoomScreen extends HookConsumerWidget {
automaticallyImplyLeading: false,
toolbarHeight: compactHeader ? null : 64,
title: chatRoom.when(
data:
(room) =>
compactHeader
data: (room) => compactHeader
? Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.center,
@@ -604,18 +909,11 @@ class ChatRoomScreen extends HookConsumerWidget {
SizedBox(
height: 26,
width: 26,
child:
(room!.type == 1 && room.picture?.id == null)
child: (room!.type == 1 && room.picture?.id == null)
? SplitAvatarWidget(
filesId:
room.members!
filesId: room.members!
.map(
(e) =>
e
.account
.profile
.picture
?.id,
(e) => e.account.profile.picture?.id,
)
.toList(),
)
@@ -633,9 +931,7 @@ class ChatRoomScreen extends HookConsumerWidget {
),
Text(
(room.type == 1 && room.name == null)
? room.members!
.map((e) => e.account.nick)
.join(', ')
? room.members!.map((e) => e.account.nick).join(', ')
: room.name!,
).fontSize(19),
],
@@ -648,18 +944,11 @@ class ChatRoomScreen extends HookConsumerWidget {
SizedBox(
height: 26,
width: 26,
child:
(room!.type == 1 && room.picture?.id == null)
child: (room!.type == 1 && room.picture?.id == null)
? SplitAvatarWidget(
filesId:
room.members!
filesId: room.members!
.map(
(e) =>
e
.account
.profile
.picture
?.id,
(e) => e.account.profile.picture?.id,
)
.toList(),
)
@@ -677,16 +966,13 @@ class ChatRoomScreen extends HookConsumerWidget {
),
Text(
(room.type == 1 && room.name == null)
? room.members!
.map((e) => e.account.nick)
.join(', ')
? room.members!.map((e) => e.account.nick).join(', ')
: room.name!,
).fontSize(15),
],
),
loading: () => const Text('Loading...'),
error:
(err, _) => ResponseErrorWidget(
error: (err, _) => ResponseErrorWidget(
error: err,
onRetry: () => messagesNotifier.loadInitial(),
),
@@ -701,6 +987,12 @@ class ChatRoomScreen extends HookConsumerWidget {
),
const Gap(8),
],
bottom: isSyncing
? const PreferredSize(
preferredSize: Size.fromHeight(4.0),
child: LinearProgressIndicator(),
)
: null,
),
body: Stack(
children: [
@@ -708,16 +1000,13 @@ class ChatRoomScreen extends HookConsumerWidget {
children: [
Expanded(
child: messages.when(
data:
(messageList) =>
messageList.isEmpty
data: (messageList) => messageList.isEmpty
? Center(child: Text('No messages yet'.tr()))
: SuperListView.builder(
listController: listController,
padding: EdgeInsets.symmetric(vertical: 16),
controller: scrollController,
reverse:
true, // Show newest messages at the bottom
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
final valueKey = key as ValueKey;
@@ -728,14 +1017,11 @@ class ChatRoomScreen extends HookConsumerWidget {
},
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
index < messageList.length - 1
final nextMessage = index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId !=
message.senderId ||
final isLastInGroup = nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
@@ -744,11 +1030,10 @@ class ChatRoomScreen extends HookConsumerWidget {
return chatIdentity.when(
skipError: true,
data:
(identity) => MessageItem(
data: (identity) => MessageItem(
key: ValueKey(message.id),
message: message,
isCurrentUser:
identity?.id == message.senderId,
isCurrentUser: identity?.id == message.senderId,
onAction: (action) {
switch (action) {
case MessageItemAction.delete:
@@ -759,17 +1044,11 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value =
message.toRemoteMessage();
messageController.text =
messageEditingTo
.value
?.content ??
'';
attachments.value =
messageEditingTo
.value!
.attachments
messageEditingTo.value?.content ?? '';
attachments.value = messageEditingTo
.value!.attachments
.map(
(e) =>
UniversalFile.fromAttachment(
(e) => UniversalFile.fromAttachment(
e,
),
)
@@ -783,8 +1062,7 @@ class ChatRoomScreen extends HookConsumerWidget {
}
},
onJump: (messageId) {
final messageIndex = messageList
.indexWhere(
final messageIndex = messageList.indexWhere(
(m) => m.id == messageId,
);
listController.jumpToItem(
@@ -794,13 +1072,10 @@ class ChatRoomScreen extends HookConsumerWidget {
alignment: 0.5,
);
},
progress:
attachmentProgress.value[message
.id],
progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup,
),
loading:
() => MessageItem(
loading: () => MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
@@ -812,18 +1087,15 @@ class ChatRoomScreen extends HookConsumerWidget {
);
},
),
loading:
() => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () => messagesNotifier.loadInitial(),
),
),
),
chatRoom.when(
data:
(room) => Column(
data: (room) => Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
@@ -854,8 +1126,7 @@ class ChatRoomScreen extends HookConsumerWidget {
),
);
},
child:
typingStatuses.value.isNotEmpty
child: typingStatuses.value.isNotEmpty
? Container(
key: const ValueKey('typing-indicator'),
width: double.infinity,
@@ -878,14 +1149,12 @@ class ChatRoomScreen extends HookConsumerWidget {
typingStatuses.value
.map(
(x) =>
x.nick ??
x.account.nick,
x.nick ?? x.account.nick,
)
.join(', '),
],
),
style:
Theme.of(
style: Theme.of(
context,
).textTheme.bodySmall,
),
@@ -1154,14 +1423,11 @@ class _ChatInput extends HookConsumerWidget {
// Insert placeholder at current cursor position
final text = messageController.text;
final selection = messageController.selection;
final start =
selection.start >= 0
final start = selection.start >= 0
? selection.start
: text.length;
final end =
selection.end >= 0
? selection.end
: text.length;
selection.end >= 0 ? selection.end : text.length;
final newText = text.replaceRange(
start,
end,
@@ -1179,8 +1445,7 @@ class _ChatInput extends HookConsumerWidget {
),
PopupMenuButton(
icon: const Icon(Symbols.photo_library),
itemBuilder:
(context) => [
itemBuilder: (context) => [
PopupMenuItem(
onTap: () => onPickFile(true),
child: Row(
@@ -1251,8 +1516,8 @@ class _ChatInput extends HookConsumerWidget {
),
),
maxLines: null,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
),

View File

@@ -6,7 +6,7 @@ part of 'room.dart';
// RiverpodGenerator
// **************************************************************************
String _$messagesNotifierHash() => r'afc4d43f4948ec571118cef0321838a6cefc89c0';
String _$messagesNotifierHash() => r'3b10c3101404f6528c7a83baa0d39cba1a30f579';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -11,6 +11,7 @@ import 'package:island/models/realm.dart';
import 'package:island/models/webfeed.dart';
import 'package:island/pods/event_calendar.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/fortune_graph.dart';
import 'package:island/widgets/app_scaffold.dart';
@@ -30,6 +31,33 @@ import 'package:styled_widget/styled_widget.dart';
part 'explore.g.dart';
Widget notificationIndicatorWidget(
BuildContext context, {
required int count,
EdgeInsets? margin,
}) => Card(
margin: margin,
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
leading: const Icon(Symbols.notifications),
title: Row(
children: [
Text('notifications').tr().fontSize(14),
const Gap(8),
Badge(label: Text(count.toString())),
],
),
trailing: const Icon(Symbols.chevron_right),
minTileHeight: 40,
contentPadding: EdgeInsets.only(left: 16, right: 15),
onTap: () {
GoRouter.of(context).pushNamed('notifications');
},
),
);
class ExploreScreen extends HookConsumerWidget {
const ExploreScreen({super.key});
@@ -77,6 +105,10 @@ class ExploreScreen extends HookConsumerWidget {
final user = ref.watch(userInfoProvider);
final notificationCount = ref.watch(
notificationUnreadCountNotifierProvider,
);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
@@ -185,7 +217,7 @@ class ExploreScreen extends HookConsumerWidget {
floatingActionButtonLocation: TabbedFabLocation(context),
body: Builder(
builder: (context) {
final isWider = isWiderScreen(context);
final isWide = isWideScreen(context);
final bodyView = _buildActivityList(
context,
@@ -193,13 +225,15 @@ class ExploreScreen extends HookConsumerWidget {
currentFilter.value,
);
if (isWider) {
if (isWide) {
return Row(
children: [
Flexible(flex: 3, child: bodyView.padding(left: 8)),
if (user.value != null)
Flexible(
flex: 2,
child: Align(
alignment: Alignment.topCenter,
child: SingleChildScrollView(
child: Column(
children: [
@@ -215,13 +249,28 @@ class ExploreScreen extends HookConsumerWidget {
);
},
),
if (notificationCount.value != null &&
notificationCount.value! > 0)
notificationIndicatorWidget(
context,
count: notificationCount.value ?? 0,
margin: EdgeInsets.only(
left: 8,
right: 12,
top: 8,
),
),
PostFeaturedList().padding(
left: 8,
right: 12,
top: 8,
),
FortuneGraphWidget(
margin: EdgeInsets.only(left: 8, right: 12, top: 8),
margin: EdgeInsets.only(
left: 8,
right: 12,
top: 8,
),
events: events,
constrainWidth: true,
onPointSelected: onDaySelected,
@@ -229,6 +278,7 @@ class ExploreScreen extends HookConsumerWidget {
],
),
),
),
)
else
Flexible(
@@ -268,7 +318,7 @@ class ExploreScreen extends HookConsumerWidget {
activityListNotifierProvider(filter).notifier,
);
final isWider = isWiderScreen(context);
final isWide = isWideScreen(context);
return RefreshIndicator(
onRefresh: () => Future.sync(activitiesNotifier.forceRefresh),
@@ -283,7 +333,7 @@ class ExploreScreen extends HookConsumerWidget {
widgetCount: widgetCount,
endItemView: endItemView,
activitiesNotifier: activitiesNotifier,
contentOnly: isWider || filter != null,
contentOnly: isWide || filter != null,
),
),
),
@@ -380,6 +430,10 @@ class _ActivityListView extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider);
final notificationCount = ref.watch(
notificationUnreadCountNotifierProvider,
);
return CustomScrollView(
slivers: [
SliverGap(12),
@@ -393,6 +447,14 @@ class _ActivityListView extends HookConsumerWidget {
SliverToBoxAdapter(
child: PostFeaturedList().padding(horizontal: 8, bottom: 4, top: 4),
),
if (!contentOnly)
SliverToBoxAdapter(
child: notificationIndicatorWidget(
context,
count: notificationCount.value ?? 0,
margin: EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 4),
),
),
SliverList.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {

View File

@@ -3,14 +3,17 @@ import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart';
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/app_scaffold.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:relative_time/relative_time.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -62,6 +65,10 @@ class NotificationUnreadCountNotifier
final current = await future;
state = AsyncData(math.max(current - count, 0));
}
void clear() async {
state = AsyncData(0);
}
}
@riverpod
@@ -111,8 +118,27 @@ class NotificationScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> markAllRead() async {
showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider);
await apiClient.post('/pusher/notifications/all/read');
if (!context.mounted) return;
hideLoadingModal(context);
ref.invalidate(notificationListNotifierProvider);
ref.watch(notificationUnreadCountNotifierProvider.notifier).clear();
}
return AppScaffold(
appBar: AppBar(title: const Text('notifications').tr()),
appBar: AppBar(
title: const Text('notifications').tr(),
actions: [
IconButton(
onPressed: markAllRead,
icon: const Icon(Symbols.mark_as_unread),
),
const Gap(8),
],
),
body: PagingHelperView(
provider: notificationListNotifierProvider,
futureRefreshable: notificationListNotifierProvider.future,

View File

@@ -7,7 +7,7 @@ part of 'notification.dart';
// **************************************************************************
String _$notificationUnreadCountNotifierHash() =>
r'd199abf0d16944587e747798399a267a790341f3';
r'0763b66bd64e5a9b7c317887e109ab367515dfa4';
/// See also [NotificationUnreadCountNotifier].
@ProviderFor(NotificationUnreadCountNotifier)

View File

@@ -51,12 +51,12 @@ class PostSearchNotifier
final offset = cursor == null ? 0 : int.parse(cursor);
final response = await client.get(
'/sphere/posts/search',
'/sphere/posts',
queryParameters: {
'query': _currentQuery,
'offset': offset,
'take': _pageSize,
'useVector': false,
'vector': false,
},
);

View File

@@ -26,7 +26,12 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
final notification = SnNotification.fromJson(pkt.data!);
showTopSnackBar(
globalOverlay.currentState!,
NotificationCard(notification: notification),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: NotificationCard(notification: notification),
),
),
onTap: () {
if (notification.meta['action_uri'] != null) {
var uri = notification.meta['action_uri'] as String;
@@ -53,9 +58,9 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
(Platform.isMacOS ||
Platform.isWindows ||
Platform.isLinux))
? 24
? 28
// ignore: use_build_context_synchronously
: MediaQuery.of(context).padding.top + 8,
: MediaQuery.of(context).padding.top + 16,
bottom: 16,
),
);

View File

@@ -162,7 +162,7 @@ class AccountSessionSheet extends HookConsumerWidget {
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.patch(
'/accounts/me/devices/$sessionId/label',
'/id/accounts/me/devices/$sessionId/label',
data: jsonEncode(label),
);
ref.invalidate(authDevicesProvider);

View File

@@ -11,7 +11,12 @@ export 'content/alert.native.dart'
void showSnackBar(String message, {SnackBarAction? action}) {
showTopSnackBar(
globalOverlay.currentState!,
Card(child: Text(message).padding(horizontal: 20, vertical: 16)),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Center(
child: Card(child: Text(message).padding(horizontal: 20, vertical: 16)),
),
),
snackBarPosition: SnackBarPosition.bottom,
);
}

View File

@@ -57,11 +57,11 @@ class EmbedLinkWidget extends StatelessWidget {
Row(
children: [
// Favicon
if (link.faviconUrl.isNotEmpty) ...[
if (link.faviconUrl?.isNotEmpty ?? false) ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
uri: link.faviconUrl,
uri: link.faviconUrl!,
width: 16,
height: 16,
fit: BoxFit.cover,
@@ -80,8 +80,8 @@ class EmbedLinkWidget extends StatelessWidget {
// Site name
Expanded(
child: Text(
link.siteName.isNotEmpty
? link.siteName
(link.siteName?.isNotEmpty ?? false)
? link.siteName!
: Uri.parse(link.url).host,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,

View File

@@ -183,9 +183,15 @@ class MarkdownTextContent extends HookConsumerWidget {
);
}
}
final content = ConstrainedBox(
final content = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 360),
child: UniversalImage(uri: uri.toString(), fit: BoxFit.contain),
child: UniversalImage(
uri: uri.toString(),
fit: BoxFit.contain,
),
),
);
return content;
},

View File

@@ -244,7 +244,6 @@ class ComposeSettingsSheet extends HookConsumerWidget {
),
// Categories field
// FIXME: Sometimes the entire dropdown crashes: 'package:flutter/src/rendering/stack.dart': Failed assertion: line 799 pos 12: 'firstChild == null || child != null': is not true.
DropdownButtonFormField2<SnPostCategory>(
isExpanded: true,
decoration: InputDecoration(
@@ -306,7 +305,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
value: currentCategories.isEmpty ? null : currentCategories.last,
onChanged: (_) {},
selectedItemBuilder: (context) {
return currentCategories.map((item) {
return (postCategories.value ?? []).map((item) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(

View File

@@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart'; // Import config.dart for shared preferences keys and provider
part 'post_featured.g.dart';
@@ -25,7 +26,13 @@ class PostFeaturedList extends HookConsumerWidget {
final featuredPostsAsync = ref.watch(featuredPostsProvider);
final pageViewController = usePageController();
final prefs = ref.watch(sharedPreferencesProvider);
final pageViewCurrent = useState(0);
final previousFirstPostId = useState<String?>(null);
final storedCollapsedId = useState<String?>(
prefs.getString(kFeaturedPostsCollapsedId),
);
final isCollapsed = useState(false);
useEffect(() {
pageViewController.addListener(() {
@@ -34,6 +41,59 @@ class PostFeaturedList extends HookConsumerWidget {
return null;
}, [pageViewController]);
// Log isCollapsed state changes
useEffect(() {
debugPrint(
'PostFeaturedList: isCollapsed changed to ${isCollapsed.value}',
);
return null;
}, [isCollapsed.value]);
useEffect(() {
if (featuredPostsAsync.hasValue && featuredPostsAsync.value!.isNotEmpty) {
final currentFirstPostId = featuredPostsAsync.value!.first.id;
debugPrint(
'PostFeaturedList: Current first post ID: $currentFirstPostId',
);
debugPrint(
'PostFeaturedList: Previous first post ID: ${previousFirstPostId.value}',
);
debugPrint(
'PostFeaturedList: Stored collapsed ID: ${storedCollapsedId.value}',
);
if (previousFirstPostId.value == null) {
// Initial load
previousFirstPostId.value = currentFirstPostId;
isCollapsed.value = (storedCollapsedId.value == currentFirstPostId);
debugPrint(
'PostFeaturedList: Initial load. isCollapsed set to ${isCollapsed.value}',
);
} else if (previousFirstPostId.value != currentFirstPostId) {
// First post changed, expand by default
previousFirstPostId.value = currentFirstPostId;
isCollapsed.value = false;
prefs.remove(
kFeaturedPostsCollapsedId,
); // Clear stored ID if post changes
debugPrint(
'PostFeaturedList: First post changed. isCollapsed set to false.',
);
} else {
// Same first post, maintain current collapse state
// No change needed for isCollapsed.value unless manually toggled
debugPrint(
'PostFeaturedList: Same first post. Maintaining current collapse state.',
);
}
} else {
debugPrint(
'PostFeaturedList: featuredPostsAsync has no value or is empty.',
);
}
return null;
}, [featuredPostsAsync.value]);
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Card(
@@ -73,10 +133,48 @@ class PostFeaturedList extends HookConsumerWidget {
},
icon: const Icon(Symbols.arrow_right),
),
IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(),
onPressed: () {
isCollapsed.value = !isCollapsed.value;
debugPrint(
'PostFeaturedList: Manual toggle. isCollapsed set to ${isCollapsed.value}',
);
if (isCollapsed.value &&
featuredPostsAsync.hasValue &&
featuredPostsAsync.value!.isNotEmpty) {
prefs.setString(
kFeaturedPostsCollapsedId,
featuredPostsAsync.value!.first.id,
);
debugPrint(
'PostFeaturedList: Stored collapsed ID: ${featuredPostsAsync.value!.first.id}',
);
} else {
prefs.remove(kFeaturedPostsCollapsedId);
debugPrint(
'PostFeaturedList: Removed stored collapsed ID.',
);
}
},
icon: Icon(
isCollapsed.value
? Symbols.expand_more
: Symbols.expand_less,
),
),
],
).padding(horizontal: 16, vertical: 8),
featuredPostsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: Visibility(
visible: !isCollapsed.value,
child: featuredPostsAsync.when(
loading:
() => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (posts) {
return SizedBox(
@@ -97,6 +195,8 @@ class PostFeaturedList extends HookConsumerWidget {
);
},
),
),
),
],
),
),

View File

@@ -879,7 +879,8 @@ class _LinkPreview extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Favicon and image
if (embed.imageUrl != null || embed.faviconUrl.isNotEmpty)
if (embed.imageUrl != null ||
(embed.faviconUrl?.isNotEmpty ?? false))
Container(
width: 60,
height: 60,
@@ -899,11 +900,14 @@ class _LinkPreview extends ConsumerWidget {
errorBuilder: (context, error, stackTrace) {
return _buildFaviconFallback(
context,
embed.faviconUrl,
embed.faviconUrl ?? '',
);
},
)
: _buildFaviconFallback(context, embed.faviconUrl),
: _buildFaviconFallback(
context,
embed.faviconUrl ?? '',
),
),
),
// Content
@@ -912,9 +916,9 @@ class _LinkPreview extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Site name
if (embed.siteName.isNotEmpty)
if (embed.siteName?.isNotEmpty ?? false)
Text(
embed.siteName,
embed.siteName!,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),

View File

@@ -41,7 +41,7 @@ endif()
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE -Wall -Wextra)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()

View File

@@ -195,7 +195,7 @@ PODS:
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- record_macos (1.0.0):
- record_macos (1.1.0):
- FlutterMacOS
- SAMKeychain (1.5.3)
- share_plus (0.0.1):
@@ -422,7 +422,7 @@ SPEC CHECKSUMS:
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
record_macos: 295d70bd5fb47145df78df7b80e6697cd18403c0
record_macos: 43194b6c06ca6f8fa132e2acea72b202b92a0f5b
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7

View File

@@ -1281,10 +1281,10 @@ packages:
dependency: "direct main"
description:
name: image_picker_platform_interface
sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0"
sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
version: "2.11.0"
image_picker_windows:
dependency: transitive
description:
@@ -1897,58 +1897,58 @@ packages:
dependency: "direct main"
description:
name: record
sha256: daeb3f9b3fea9797094433fe6e49a879d8e4ca4207740bc6dc7e4a58764f0817
sha256: "3d08502b77edf2a864aa6e4cd7874b983d42a80f3689431da053cc5e85c1ad21"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
version: "6.1.0"
record_android:
dependency: transitive
description:
name: record_android
sha256: "97d7122455f30de89a01c6c244c839085be6b12abca251fc0e78f67fed73628b"
sha256: "8b170e33d9866f9b51e01a767d7e1ecb97b9ecd629950bd87a47c79359ec57f8"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
version: "1.4.0"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: "73706ebbece6150654c9d6f57897cf9b622c581148304132ba85dba15df0fdfb"
sha256: ad97d0a75933c44bcf5aff648e86e32fc05eb61f8fbef190f14968c8eaf86692
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.1.0"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95"
sha256: "785e8e8d6db109aa606d0669d95aaae416458aaa39782bb0abe0bee74eee17d7"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.2.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "02240833fde16c33fcf2c589f3e08d4394b704761b4a3bb609d872ff3043fbbd"
sha256: f1399bca76a1634da109e5b0cba764ed8332a2b4da49c704c66d2c553405ed81
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.1.0"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: c1ad38f51e4af88a085b3e792a22c685cb3e7c23fc37aa7ce44c4cf18f25fe89
sha256: b0065fdf1ec28f5a634d676724d388a77e43ce7646fb049949f58c69f3fcb4ed
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: a12856d0b3dd03d336b4b10d7520a8b3e21649a06a8f95815318feaa8f07adbb
sha256: "4f0adf20c9ccafcc02d71111fd91fba1ca7b17a7453902593e5a9b25b74a5c56"
url: "https://pub.dev"
source: hosted
version: "1.1.9"
version: "1.2.0"
record_windows:
dependency: transitive
description:
@@ -2568,10 +2568,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331"
sha256: ca81fdfaf62a5ab45d7296614aea108d2c7d0efca8393e96174bf4d51e6725b0
url: "https://pub.dev"
source: hosted
version: "1.1.17"
version: "1.1.18"
vector_math:
dependency: transitive
description:

View File

@@ -75,7 +75,7 @@ dependencies:
image_picker: ^1.1.2
file_picker: ^10.3.1
riverpod_annotation: ^2.6.1
image_picker_platform_interface: ^2.10.1
image_picker_platform_interface: ^2.11.0
image_picker_android: ^0.8.12+25
super_context_menu: ^0.9.1
modal_bottom_sheet: ^3.0.0
@@ -107,7 +107,7 @@ dependencies:
livekit_client: ^2.5.0+hotfix.1
pasteboard: ^0.4.0
flutter_colorpicker: ^1.1.0
record: ^6.0.0
record: ^6.1.0
qr_flutter: ^4.1.0
flutter_otp_text_field: ^1.5.1+1
palette_generator: ^0.3.3+7