👽 Support the latest API

This commit is contained in:
LittleSheep 2025-05-24 01:34:20 +08:00
parent 942b62fbff
commit fc2520b8f8
16 changed files with 426 additions and 86 deletions

View File

@ -263,5 +263,10 @@
"notifications": "Notifications",
"posts": "Posts",
"settingsBackgroundImage": "Background Image",
"settingsBackgroundImageClear": "Clear Background Image"
"settingsBackgroundImageClear": "Clear Background Image",
"messageNone": "No content to display",
"unreadMessages": {
"one": "{} unread message",
"other": "{} unread messages"
}
}

View File

@ -456,12 +456,4 @@ class MessageRepository {
rethrow;
}
}
Future<void> markMessageAsRead(String messageId) async {
try {
await _database.markMessageAsRead(messageId);
} catch (e) {
showErrorAlert(e);
}
}
}

View File

@ -27,7 +27,10 @@ import 'package:flutter_native_splash/flutter_native_splash.dart';
void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
}
await EasyLocalization.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
@ -51,7 +54,9 @@ void main() async {
}
}
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
FlutterNativeSplash.remove();
}
runApp(
ProviderScope(

View File

@ -95,6 +95,17 @@ abstract class SnChatMember with _$SnChatMember {
_$SnChatMemberFromJson(json);
}
@freezed
abstract class SnChatSummary with _$SnChatSummary {
const factory SnChatSummary({
required int unreadCount,
required SnChatMessage lastMessage,
}) = _SnChatSummary;
factory SnChatSummary.fromJson(Map<String, dynamic> json) =>
_$SnChatSummaryFromJson(json);
}
class MessageChangeAction {
static const String create = "create";
static const String update = "update";

View File

@ -874,6 +874,160 @@ $SnAccountCopyWith<$Res> get account {
}
/// @nodoc
mixin _$SnChatSummary {
int get unreadCount; SnChatMessage get lastMessage;
/// Create a copy of SnChatSummary
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnChatSummaryCopyWith<SnChatSummary> get copyWith => _$SnChatSummaryCopyWithImpl<SnChatSummary>(this as SnChatSummary, _$identity);
/// Serializes this SnChatSummary to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatSummary&&(identical(other.unreadCount, unreadCount) || other.unreadCount == unreadCount)&&(identical(other.lastMessage, lastMessage) || other.lastMessage == lastMessage));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,unreadCount,lastMessage);
@override
String toString() {
return 'SnChatSummary(unreadCount: $unreadCount, lastMessage: $lastMessage)';
}
}
/// @nodoc
abstract mixin class $SnChatSummaryCopyWith<$Res> {
factory $SnChatSummaryCopyWith(SnChatSummary value, $Res Function(SnChatSummary) _then) = _$SnChatSummaryCopyWithImpl;
@useResult
$Res call({
int unreadCount, SnChatMessage lastMessage
});
$SnChatMessageCopyWith<$Res> get lastMessage;
}
/// @nodoc
class _$SnChatSummaryCopyWithImpl<$Res>
implements $SnChatSummaryCopyWith<$Res> {
_$SnChatSummaryCopyWithImpl(this._self, this._then);
final SnChatSummary _self;
final $Res Function(SnChatSummary) _then;
/// Create a copy of SnChatSummary
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? unreadCount = null,Object? lastMessage = null,}) {
return _then(_self.copyWith(
unreadCount: null == unreadCount ? _self.unreadCount : unreadCount // ignore: cast_nullable_to_non_nullable
as int,lastMessage: null == lastMessage ? _self.lastMessage : lastMessage // ignore: cast_nullable_to_non_nullable
as SnChatMessage,
));
}
/// Create a copy of SnChatSummary
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnChatMessageCopyWith<$Res> get lastMessage {
return $SnChatMessageCopyWith<$Res>(_self.lastMessage, (value) {
return _then(_self.copyWith(lastMessage: value));
});
}
}
/// @nodoc
@JsonSerializable()
class _SnChatSummary implements SnChatSummary {
const _SnChatSummary({required this.unreadCount, required this.lastMessage});
factory _SnChatSummary.fromJson(Map<String, dynamic> json) => _$SnChatSummaryFromJson(json);
@override final int unreadCount;
@override final SnChatMessage lastMessage;
/// Create a copy of SnChatSummary
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnChatSummaryCopyWith<_SnChatSummary> get copyWith => __$SnChatSummaryCopyWithImpl<_SnChatSummary>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnChatSummaryToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatSummary&&(identical(other.unreadCount, unreadCount) || other.unreadCount == unreadCount)&&(identical(other.lastMessage, lastMessage) || other.lastMessage == lastMessage));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,unreadCount,lastMessage);
@override
String toString() {
return 'SnChatSummary(unreadCount: $unreadCount, lastMessage: $lastMessage)';
}
}
/// @nodoc
abstract mixin class _$SnChatSummaryCopyWith<$Res> implements $SnChatSummaryCopyWith<$Res> {
factory _$SnChatSummaryCopyWith(_SnChatSummary value, $Res Function(_SnChatSummary) _then) = __$SnChatSummaryCopyWithImpl;
@override @useResult
$Res call({
int unreadCount, SnChatMessage lastMessage
});
@override $SnChatMessageCopyWith<$Res> get lastMessage;
}
/// @nodoc
class __$SnChatSummaryCopyWithImpl<$Res>
implements _$SnChatSummaryCopyWith<$Res> {
__$SnChatSummaryCopyWithImpl(this._self, this._then);
final _SnChatSummary _self;
final $Res Function(_SnChatSummary) _then;
/// Create a copy of SnChatSummary
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? unreadCount = null,Object? lastMessage = null,}) {
return _then(_SnChatSummary(
unreadCount: null == unreadCount ? _self.unreadCount : unreadCount // ignore: cast_nullable_to_non_nullable
as int,lastMessage: null == lastMessage ? _self.lastMessage : lastMessage // ignore: cast_nullable_to_non_nullable
as SnChatMessage,
));
}
/// Create a copy of SnChatSummary
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnChatMessageCopyWith<$Res> get lastMessage {
return $SnChatMessageCopyWith<$Res>(_self.lastMessage, (value) {
return _then(_self.copyWith(lastMessage: value));
});
}
}
/// @nodoc
mixin _$MessageChange {

View File

@ -188,6 +188,20 @@ Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) =>
'is_bot': instance.isBot,
};
_SnChatSummary _$SnChatSummaryFromJson(Map<String, dynamic> json) =>
_SnChatSummary(
unreadCount: (json['unread_count'] as num).toInt(),
lastMessage: SnChatMessage.fromJson(
json['last_message'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$SnChatSummaryToJson(_SnChatSummary instance) =>
<String, dynamic>{
'unread_count': instance.unreadCount,
'last_message': instance.lastMessage.toJson(),
};
_MessageChange _$MessageChangeFromJson(Map<String, dynamic> json) =>
_MessageChange(
messageId: json['message_id'] as String,

View File

@ -0,0 +1,64 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/network.dart';
part 'chat_summary.g.dart';
@riverpod
class ChatSummary extends _$ChatSummary {
@override
Future<Map<String, SnChatSummary>> build() async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/chat/summary');
final Map<String, dynamic> data = resp.data;
return data.map(
(key, value) => MapEntry(key, SnChatSummary.fromJson(value)),
);
}
Future<void> clearUnreadCount(String chatId) async {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null) {
state = AsyncData({
...summaries,
chatId: SnChatSummary(
unreadCount: 0,
lastMessage: summary.lastMessage,
),
});
}
});
}
void updateLastMessage(String chatId, SnChatMessage message) {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null) {
state = AsyncData({
...summaries,
chatId: SnChatSummary(
unreadCount: summary.unreadCount + 1,
lastMessage: message,
),
});
}
});
}
void incrementUnreadCount(String chatId) {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null) {
state = AsyncData({
...summaries,
chatId: SnChatSummary(
unreadCount: summary.unreadCount + 1,
lastMessage: summary.lastMessage,
),
});
}
});
}
}

View File

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_summary.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatSummaryHash() => r'fa48d381f489f90055fb728f7e0fda6f8ef49d15';
/// See also [ChatSummary].
@ProviderFor(ChatSummary)
final chatSummaryProvider = AutoDisposeAsyncNotifierProvider<
ChatSummary,
Map<String, SnChatSummary>
>.internal(
ChatSummary.new,
name: r'chatSummaryProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$chatSummaryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ChatSummary = AutoDisposeAsyncNotifier<Map<String, SnChatSummary>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -61,8 +61,8 @@ class AccountProfileRoute extends _i26.PageRouteInfo<AccountProfileRouteArgs> {
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<AccountProfileRouteArgs>(
orElse:
() => AccountProfileRouteArgs(name: pathParams.getString('name')),
orElse: () =>
AccountProfileRouteArgs(name: pathParams.getString('name')),
);
return _i1.AccountProfileScreen(key: args.key, name: args.name);
},
@ -508,8 +508,7 @@ class EditStickerPacksRoute
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<EditStickerPacksRouteArgs>(
orElse:
() => EditStickerPacksRouteArgs(
orElse: () => EditStickerPacksRouteArgs(
pubName: pathParams.getString('name'),
packId: pathParams.optString('packId'),
),
@ -564,8 +563,7 @@ class EditStickersRoute extends _i26.PageRouteInfo<EditStickersRouteArgs> {
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<EditStickersRouteArgs>(
orElse:
() => EditStickersRouteArgs(
orElse: () => EditStickersRouteArgs(
packId: pathParams.getString('packId'),
id: pathParams.optString('id'),
),
@ -716,8 +714,7 @@ class NewStickerPacksRoute
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<NewStickerPacksRouteArgs>(
orElse:
() =>
orElse: () =>
NewStickerPacksRouteArgs(pubName: pathParams.getString('name')),
);
return _i11.NewStickerPacksScreen(key: args.key, pubName: args.pubName);
@ -759,8 +756,8 @@ class NewStickersRoute extends _i26.PageRouteInfo<NewStickersRouteArgs> {
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<NewStickersRouteArgs>(
orElse:
() => NewStickersRouteArgs(packId: pathParams.getString('packId')),
orElse: () =>
NewStickersRouteArgs(packId: pathParams.getString('packId')),
);
return _i12.NewStickersScreen(key: args.key, packId: args.packId);
},
@ -942,8 +939,8 @@ class PublisherProfileRoute
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<PublisherProfileRouteArgs>(
orElse:
() => PublisherProfileRouteArgs(name: pathParams.getString('name')),
orElse: () =>
PublisherProfileRouteArgs(name: pathParams.getString('name')),
);
return _i19.PublisherProfileScreen(key: args.key, name: args.name);
},
@ -1075,8 +1072,7 @@ class StickerPackDetailRoute
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs<StickerPackDetailRouteArgs>(
orElse:
() => StickerPackDetailRouteArgs(
orElse: () => StickerPackDetailRouteArgs(
pubName: pathParams.getString('name'),
id: pathParams.getString('packId'),
),

View File

@ -130,13 +130,13 @@ class AccountProfileScreen extends HookConsumerWidget {
SliverToBoxAdapter(
child: const Divider(height: 1).padding(bottom: 24),
),
if (data.profile.bio?.isNotEmpty ?? false)
if (data.profile.bio.isNotEmpty)
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('bio').tr().bold(),
Text(data.profile.bio!),
Text(data.profile.bio),
],
).padding(horizontal: 24),
),

View File

@ -96,7 +96,8 @@ class RelationshipListTile extends StatelessWidget {
relationship.status == 0 && relationship.relatedId == currentUserId;
final isWaiting =
relationship.status == 0 && relationship.accountId == currentUserId;
final isEstablished = relationship.status == 1 || relationship.status == 2;
final isEstablished =
relationship.status >= 100 || relationship.status <= -100;
return ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 12),
@ -105,13 +106,13 @@ class RelationshipListTile extends StatelessWidget {
spacing: 6,
children: [
Flexible(child: Text(account.nick)),
if (relationship.status == 1) // Friend
if (relationship.status >= 100) // Friend
Badge(
label: Text('relationshipStatusFriend').tr(),
backgroundColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
)
else if (relationship.status == 2) // Blocked
else if (relationship.status <= -100) // Blocked
Badge(
label: Text('relationshipStatusBlocked').tr(),
backgroundColor: Theme.of(context).colorScheme.error,
@ -171,7 +172,7 @@ class RelationshipListTile extends StatelessWidget {
icon: const Icon(Symbols.more_vert),
itemBuilder:
(context) => [
if (relationship.status == 1) // If friend
if (relationship.status >= 100) // If friend
PopupMenuItem(
child: ListTile(
leading: const Icon(Symbols.block),
@ -179,9 +180,12 @@ class RelationshipListTile extends StatelessWidget {
contentPadding: EdgeInsets.zero,
),
onTap:
() => onUpdateStatus?.call(relationship, 2),
() => onUpdateStatus?.call(
relationship,
-100,
),
)
else if (relationship.status == 2) // If blocked
else if (relationship.status <= -100) // If blocked
PopupMenuItem(
child: ListTile(
leading: const Icon(Symbols.person_add),
@ -189,7 +193,8 @@ class RelationshipListTile extends StatelessWidget {
contentPadding: EdgeInsets.zero,
),
onTap:
() => onUpdateStatus?.call(relationship, 1),
() =>
onUpdateStatus?.call(relationship, 100),
),
],
),

View File

@ -110,6 +110,7 @@ class TabsNavigationWidget extends HookConsumerWidget {
Gap(MediaQuery.of(context).padding.top + 8),
Expanded(
child: NavigationRail(
minExtendedWidth: 200,
extended: useExpandableLayout,
selectedIndex: activeIndex,
onDestinationSelected: (index) {

View File

@ -11,6 +11,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/file.dart';
import 'package:island/models/realm.dart';
import 'package:island/pods/chat_summary.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
@ -24,12 +25,13 @@ import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/realms/selection_dropdown.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'chat.g.dart';
class ChatRoomListTile extends StatelessWidget {
class ChatRoomListTile extends HookConsumerWidget {
final SnChatRoom room;
final bool isDirect;
final Widget? subtitle;
@ -46,7 +48,88 @@ class ChatRoomListTile extends StatelessWidget {
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref
.watch(chatSummaryProvider)
.whenData((summaries) => summaries[room.id]);
Widget buildSubtitle() {
if (subtitle != null) return subtitle!;
return summary.when(
data: (data) {
if (data == null) {
return isDirect && room.description == null
? Text(
room.members!.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (data.unreadCount > 0)
Text(
'unreadMessages'.plural(data.unreadCount),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
Row(
children: [
Text(
'${data.lastMessage.sender.account.name}: ',
style: Theme.of(context).textTheme.bodySmall,
),
Expanded(
child: Text(
data.lastMessage.content ?? 'messageNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
RelativeTime(context).format(data.lastMessage.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
);
},
loading: () => const SizedBox.shrink(),
error:
(_, __) =>
isDirect && room.description == null
? Text(
room.members!.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
),
);
}
Widget buildTrailing() {
if (trailing != null) return trailing!;
return summary.when(
data: (data) {
if (data == null || data.unreadCount == 0) {
return const SizedBox.shrink();
}
return Badge(label: Text(data.unreadCount.toString()));
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
return ListTile(
leading:
(isDirect && room.pictureId == null)
@ -62,19 +145,20 @@ class ChatRoomListTile extends StatelessWidget {
title: Text(
(isDirect && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ')
: room.name!,
: room.name ?? '',
),
subtitle:
subtitle != null
? subtitle!
: (isDirect && room.description == null)
? Text(
room.members!.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1),
trailing: trailing,
onTap: onTap,
subtitle: buildSubtitle(),
trailing: buildTrailing(),
onTap: () async {
// Clear unread count if there are unread messages
final summary = await ref.read(chatSummaryProvider.future);
if ((summary[room.id]?.unreadCount ?? 0) > 0) {
await ref
.read(chatSummaryProvider.notifier)
.clearUnreadCount(room.id);
}
onTap?.call();
},
);
}
}
@ -100,9 +184,9 @@ class ChatShellScreen extends HookConsumerWidget {
if (isWide) {
return Row(
children: [
SizedBox(width: 320, child: ChatListScreen(isAside: true)),
Flexible(flex: 2, child: ChatListScreen(isAside: true)),
VerticalDivider(width: 1),
Expanded(child: AutoRouter()),
Flexible(flex: 4, child: AutoRouter()),
],
);
}

View File

@ -311,27 +311,14 @@ class ChatRoomScreen extends HookConsumerWidget {
final attachmentProgress = useState<Map<String, Map<int, double>>>({});
// Function to send read receipt
void sendReadReceipt(String messageId) async {
// Get message from repository to check read status
final repository = await ref.read(messageRepositoryProvider(id).future);
final message = await repository.getMessageById(messageId);
// Skip if message is already marked as read
if (message?.isRead ?? false) return;
void sendReadReceipt() async {
// Send websocket packet
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(
type: 'messages.read',
data: {'chat_room_id': id, 'message_id': messageId},
),
WebSocketPacket(type: 'messages.read', data: {'chat_room_id': id}),
),
);
// Mark as read in local database
await repository.markMessageAsRead(messageId);
}
// Add scroll listener for pagination
@ -357,7 +344,7 @@ class ChatRoomScreen extends HookConsumerWidget {
case 'messages.new':
messagesNotifier.receiveMessage(message);
// Send read receipt for new message
sendReadReceipt(message.id);
sendReadReceipt();
case 'messages.update':
messagesNotifier.receiveMessageUpdate(message);
case 'messages.delete':
@ -365,6 +352,7 @@ class ChatRoomScreen extends HookConsumerWidget {
}
}
sendReadReceipt();
final subscription = ws.dataStream.listen(onMessage);
return () => subscription.cancel();
}, [ws, chatRoom]);
@ -553,8 +541,6 @@ class ChatRoomScreen extends HookConsumerWidget {
nextMessage == null ||
nextMessage.senderId != message.senderId;
sendReadReceipt(message.id);
return chatIdentity.when(
skipError: true,
data:

View File

@ -285,7 +285,7 @@ class EditPublisherScreen extends HookConsumerWidget {
final user = ref.watch(userInfoProvider);
nameController.text = user.value!.name;
nickController.text = user.value!.nick;
bioController.text = user.value!.profile.bio ?? '';
bioController.text = user.value!.profile.bio;
picture.value = user.value!.profile.pictureId;
background.value = user.value!.profile.backgroundId;
} else {

View File

@ -120,13 +120,9 @@ class RealmListScreen extends HookConsumerWidget {
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(e, _) => GestureDetector(
child: Center(
child: Text('Error: $e', textAlign: TextAlign.center),
),
onTap: () {
ref.invalidate(realmsJoinedProvider);
},
(e, _) => ResponseErrorWidget(
error: e,
onRetry: () => ref.invalidate(realmsJoinedProvider),
),
),
onRefresh: () => ref.refresh(realmsJoinedProvider.future),