🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

View File

@@ -0,0 +1,179 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/realms/realms_models/realm.dart';
import 'package:island/accounts/accounts_models/account.dart';
part 'chat.freezed.dart';
part 'chat.g.dart';
@freezed
sealed class SnChatRoom with _$SnChatRoom {
const factory SnChatRoom({
required String id,
required String? name,
required String? description,
required int type,
@Default(false) bool isPublic,
@Default(false) bool isCommunity,
required SnCloudFile? picture,
required SnCloudFile? background,
required String? realmId,
required String? accountId,
required SnRealm? realm,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required List<SnChatMember>? members,
// Frontend data
@Default(false) bool isPinned,
}) = _SnChatRoom;
factory SnChatRoom.fromJson(Map<String, dynamic> json) =>
_$SnChatRoomFromJson(json);
}
@freezed
sealed class SnChatMessage with _$SnChatMessage {
const factory SnChatMessage({
required DateTime createdAt,
required DateTime updatedAt,
DateTime? deletedAt,
required String id,
@Default('text') String type,
String? content,
String? nonce,
@Default({}) Map<String, dynamic> meta,
@Default([]) List<String> membersMentioned,
DateTime? editedAt,
@Default([]) List<SnCloudFile> attachments,
@Default([]) List<SnChatReaction> reactions,
String? repliedMessageId,
String? forwardedMessageId,
required String senderId,
required SnChatMember sender,
required String chatRoomId,
}) = _SnChatMessage;
factory SnChatMessage.fromJson(Map<String, dynamic> json) =>
_$SnChatMessageFromJson(json);
}
@freezed
sealed class SnChatReaction with _$SnChatReaction {
const factory SnChatReaction({
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String id,
required String messageId,
required String senderId,
required SnChatMember sender,
required String symbol,
required int attitude,
}) = _SnChatReaction;
factory SnChatReaction.fromJson(Map<String, dynamic> json) =>
_$SnChatReactionFromJson(json);
}
@freezed
sealed class SnChatMember with _$SnChatMember {
const factory SnChatMember({
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String id,
required String chatRoomId,
required SnChatRoom? chatRoom,
required String accountId,
required SnAccount account,
required String? nick,
required int notify,
required DateTime? joinedAt,
required DateTime? breakUntil,
required DateTime? timeoutUntil,
required SnAccountStatus? status,
// Frontend data
DateTime? lastTyped,
}) = _SnChatMember;
factory SnChatMember.fromJson(Map<String, dynamic> json) =>
_$SnChatMemberFromJson(json);
}
@freezed
sealed 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";
static const String delete = "delete";
}
@freezed
sealed class MessageSyncResponse with _$MessageSyncResponse {
const factory MessageSyncResponse({
@Default([]) List<SnChatMessage> messages,
required DateTime currentTimestamp,
}) = _MessageSyncResponse;
factory MessageSyncResponse.fromJson(Map<String, dynamic> json) =>
_$MessageSyncResponseFromJson(json);
}
@freezed
sealed class ChatRealtimeJoinResponse with _$ChatRealtimeJoinResponse {
const factory ChatRealtimeJoinResponse({
required String provider,
required String endpoint,
required String token,
required String callId,
required String roomName,
required bool isAdmin,
required List<CallParticipant> participants,
}) = _ChatRealtimeJoinResponse;
factory ChatRealtimeJoinResponse.fromJson(Map<String, dynamic> json) =>
_$ChatRealtimeJoinResponseFromJson(json);
}
@freezed
sealed class CallParticipant with _$CallParticipant {
const factory CallParticipant({
required String identity,
required String name,
required DateTime joinedAt,
}) = _CallParticipant;
factory CallParticipant.fromJson(Map<String, dynamic> json) =>
_$CallParticipantFromJson(json);
}
@freezed
sealed class SnRealtimeCall with _$SnRealtimeCall {
const factory SnRealtimeCall({
required String id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required DateTime? endedAt,
required String senderId,
required SnChatMember sender,
required String roomId,
required SnChatRoom room,
required Map<String, dynamic> upstreamConfig,
String? providerName,
String? sessionId,
}) = _SnRealtimeCall;
factory SnRealtimeCall.fromJson(Map<String, dynamic> json) =>
_$SnRealtimeCallFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,303 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnChatRoom _$SnChatRoomFromJson(Map<String, dynamic> json) => _SnChatRoom(
id: json['id'] as String,
name: json['name'] as String?,
description: json['description'] as String?,
type: (json['type'] as num).toInt(),
isPublic: json['is_public'] as bool? ?? false,
isCommunity: json['is_community'] as bool? ?? false,
picture: json['picture'] == null
? null
: SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>),
background: json['background'] == null
? null
: SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
realmId: json['realm_id'] as String?,
accountId: json['account_id'] as String?,
realm: json['realm'] == null
? null
: SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
members: (json['members'] as List<dynamic>?)
?.map((e) => SnChatMember.fromJson(e as Map<String, dynamic>))
.toList(),
isPinned: json['is_pinned'] as bool? ?? false,
);
Map<String, dynamic> _$SnChatRoomToJson(_SnChatRoom instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'description': instance.description,
'type': instance.type,
'is_public': instance.isPublic,
'is_community': instance.isCommunity,
'picture': instance.picture?.toJson(),
'background': instance.background?.toJson(),
'realm_id': instance.realmId,
'account_id': instance.accountId,
'realm': instance.realm?.toJson(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'members': instance.members?.map((e) => e.toJson()).toList(),
'is_pinned': instance.isPinned,
};
_SnChatMessage _$SnChatMessageFromJson(Map<String, dynamic> json) =>
_SnChatMessage(
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
id: json['id'] as String,
type: json['type'] as String? ?? 'text',
content: json['content'] as String?,
nonce: json['nonce'] as String?,
meta: json['meta'] as Map<String, dynamic>? ?? const {},
membersMentioned:
(json['members_mentioned'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
editedAt: json['edited_at'] == null
? null
: DateTime.parse(json['edited_at'] as String),
attachments:
(json['attachments'] as List<dynamic>?)
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
reactions:
(json['reactions'] as List<dynamic>?)
?.map((e) => SnChatReaction.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
repliedMessageId: json['replied_message_id'] as String?,
forwardedMessageId: json['forwarded_message_id'] as String?,
senderId: json['sender_id'] as String,
sender: SnChatMember.fromJson(json['sender'] as Map<String, dynamic>),
chatRoomId: json['chat_room_id'] as String,
);
Map<String, dynamic> _$SnChatMessageToJson(_SnChatMessage instance) =>
<String, dynamic>{
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'id': instance.id,
'type': instance.type,
'content': instance.content,
'nonce': instance.nonce,
'meta': instance.meta,
'members_mentioned': instance.membersMentioned,
'edited_at': instance.editedAt?.toIso8601String(),
'attachments': instance.attachments.map((e) => e.toJson()).toList(),
'reactions': instance.reactions.map((e) => e.toJson()).toList(),
'replied_message_id': instance.repliedMessageId,
'forwarded_message_id': instance.forwardedMessageId,
'sender_id': instance.senderId,
'sender': instance.sender.toJson(),
'chat_room_id': instance.chatRoomId,
};
_SnChatReaction _$SnChatReactionFromJson(Map<String, dynamic> json) =>
_SnChatReaction(
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
id: json['id'] as String,
messageId: json['message_id'] as String,
senderId: json['sender_id'] as String,
sender: SnChatMember.fromJson(json['sender'] as Map<String, dynamic>),
symbol: json['symbol'] as String,
attitude: (json['attitude'] as num).toInt(),
);
Map<String, dynamic> _$SnChatReactionToJson(_SnChatReaction instance) =>
<String, dynamic>{
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'id': instance.id,
'message_id': instance.messageId,
'sender_id': instance.senderId,
'sender': instance.sender.toJson(),
'symbol': instance.symbol,
'attitude': instance.attitude,
};
_SnChatMember _$SnChatMemberFromJson(Map<String, dynamic> json) =>
_SnChatMember(
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
id: json['id'] as String,
chatRoomId: json['chat_room_id'] as String,
chatRoom: json['chat_room'] == null
? null
: SnChatRoom.fromJson(json['chat_room'] as Map<String, dynamic>),
accountId: json['account_id'] as String,
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
nick: json['nick'] as String?,
notify: (json['notify'] as num).toInt(),
joinedAt: json['joined_at'] == null
? null
: DateTime.parse(json['joined_at'] as String),
breakUntil: json['break_until'] == null
? null
: DateTime.parse(json['break_until'] as String),
timeoutUntil: json['timeout_until'] == null
? null
: DateTime.parse(json['timeout_until'] as String),
status: json['status'] == null
? null
: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
lastTyped: json['last_typed'] == null
? null
: DateTime.parse(json['last_typed'] as String),
);
Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) =>
<String, dynamic>{
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'id': instance.id,
'chat_room_id': instance.chatRoomId,
'chat_room': instance.chatRoom?.toJson(),
'account_id': instance.accountId,
'account': instance.account.toJson(),
'nick': instance.nick,
'notify': instance.notify,
'joined_at': instance.joinedAt?.toIso8601String(),
'break_until': instance.breakUntil?.toIso8601String(),
'timeout_until': instance.timeoutUntil?.toIso8601String(),
'status': instance.status?.toJson(),
'last_typed': instance.lastTyped?.toIso8601String(),
};
_SnChatSummary _$SnChatSummaryFromJson(Map<String, dynamic> json) =>
_SnChatSummary(
unreadCount: (json['unread_count'] as num).toInt(),
lastMessage: json['last_message'] == null
? null
: 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(),
};
_MessageSyncResponse _$MessageSyncResponseFromJson(Map<String, dynamic> json) =>
_MessageSyncResponse(
messages:
(json['messages'] as List<dynamic>?)
?.map((e) => SnChatMessage.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
currentTimestamp: DateTime.parse(json['current_timestamp'] as String),
);
Map<String, dynamic> _$MessageSyncResponseToJson(
_MessageSyncResponse instance,
) => <String, dynamic>{
'messages': instance.messages.map((e) => e.toJson()).toList(),
'current_timestamp': instance.currentTimestamp.toIso8601String(),
};
_ChatRealtimeJoinResponse _$ChatRealtimeJoinResponseFromJson(
Map<String, dynamic> json,
) => _ChatRealtimeJoinResponse(
provider: json['provider'] as String,
endpoint: json['endpoint'] as String,
token: json['token'] as String,
callId: json['call_id'] as String,
roomName: json['room_name'] as String,
isAdmin: json['is_admin'] as bool,
participants: (json['participants'] as List<dynamic>)
.map((e) => CallParticipant.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$ChatRealtimeJoinResponseToJson(
_ChatRealtimeJoinResponse instance,
) => <String, dynamic>{
'provider': instance.provider,
'endpoint': instance.endpoint,
'token': instance.token,
'call_id': instance.callId,
'room_name': instance.roomName,
'is_admin': instance.isAdmin,
'participants': instance.participants.map((e) => e.toJson()).toList(),
};
_CallParticipant _$CallParticipantFromJson(Map<String, dynamic> json) =>
_CallParticipant(
identity: json['identity'] as String,
name: json['name'] as String,
joinedAt: DateTime.parse(json['joined_at'] as String),
);
Map<String, dynamic> _$CallParticipantToJson(_CallParticipant instance) =>
<String, dynamic>{
'identity': instance.identity,
'name': instance.name,
'joined_at': instance.joinedAt.toIso8601String(),
};
_SnRealtimeCall _$SnRealtimeCallFromJson(Map<String, dynamic> json) =>
_SnRealtimeCall(
id: json['id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
endedAt: json['ended_at'] == null
? null
: DateTime.parse(json['ended_at'] as String),
senderId: json['sender_id'] as String,
sender: SnChatMember.fromJson(json['sender'] as Map<String, dynamic>),
roomId: json['room_id'] as String,
room: SnChatRoom.fromJson(json['room'] as Map<String, dynamic>),
upstreamConfig: json['upstream_config'] as Map<String, dynamic>,
providerName: json['provider_name'] as String?,
sessionId: json['session_id'] as String?,
);
Map<String, dynamic> _$SnRealtimeCallToJson(_SnRealtimeCall instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'ended_at': instance.endedAt?.toIso8601String(),
'sender_id': instance.senderId,
'sender': instance.sender.toJson(),
'room_id': instance.roomId,
'room': instance.room.toJson(),
'upstream_config': instance.upstreamConfig,
'provider_name': instance.providerName,
'session_id': instance.sessionId,
};

View File

@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/core/network.dart';
import 'package:island/core/websocket.dart';
import 'package:island/accounts/accounts_models/account.dart';
part 'chat_online_count.g.dart';
@riverpod
class ChatOnlineCountNotifier extends _$ChatOnlineCountNotifier {
@override
Future<int> build(String chatroomId) async {
final apiClient = ref.watch(apiClientProvider);
final ws = ref.watch(websocketProvider);
// Fetch initial online count
final response = await apiClient.get(
'/messager/chat/$chatroomId/members/online',
);
final initialCount = response.data as int;
// Listen for websocket status updates
final subscription = ws.dataStream.listen((WebSocketPacket packet) {
if (packet.type == 'accounts.status.update') {
final data = packet.data;
if (data != null && data['chat_room_id'] == chatroomId) {
final status = SnAccountStatus.fromJson(data['status']);
var delta = status.isOnline ? 1 : -1;
if (status.clearedAt != null &&
status.clearedAt!.isBefore(DateTime.now())) {
if (status.isInvisible) delta = 1;
}
// Update count based on online status
state.whenData((currentCount) {
final newCount = currentCount + delta;
state = AsyncData(
newCount.clamp(0, double.infinity).toInt(),
); // Ensure non-negative
});
}
}
});
ref.onDispose(() {
subscription.cancel();
});
return initialCount;
}
}

View File

@@ -0,0 +1,101 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_online_count.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatOnlineCountNotifier)
final chatOnlineCountProvider = ChatOnlineCountNotifierFamily._();
final class ChatOnlineCountNotifierProvider
extends $AsyncNotifierProvider<ChatOnlineCountNotifier, int> {
ChatOnlineCountNotifierProvider._({
required ChatOnlineCountNotifierFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'chatOnlineCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatOnlineCountNotifierHash();
@override
String toString() {
return r'chatOnlineCountProvider'
''
'($argument)';
}
@$internal
@override
ChatOnlineCountNotifier create() => ChatOnlineCountNotifier();
@override
bool operator ==(Object other) {
return other is ChatOnlineCountNotifierProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatOnlineCountNotifierHash() =>
r'b2f9f17bfece1937ec90590b8f11db2bec923156';
final class ChatOnlineCountNotifierFamily extends $Family
with
$ClassFamilyOverride<
ChatOnlineCountNotifier,
AsyncValue<int>,
int,
FutureOr<int>,
String
> {
ChatOnlineCountNotifierFamily._()
: super(
retry: null,
name: r'chatOnlineCountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatOnlineCountNotifierProvider call(String chatroomId) =>
ChatOnlineCountNotifierProvider._(argument: chatroomId, from: this);
@override
String toString() => r'chatOnlineCountProvider';
}
abstract class _$ChatOnlineCountNotifier extends $AsyncNotifier<int> {
late final _$args = ref.$arg as String;
String get chatroomId => _$args;
FutureOr<int> build(String chatroomId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<int>, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<int>, int>,
AsyncValue<int>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}

450
lib/chat/chat_pod/call.dart Normal file
View File

@@ -0,0 +1,450 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:island/chat/chat_widgets/call_button.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/core/network.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:island/talker.dart';
part 'call.g.dart';
part 'call.freezed.dart';
enum ViewMode { grid, stage }
String formatDuration(Duration duration) {
String negativeSign = duration.isNegative ? '-' : '';
String twoDigits(int n) => n.toString().padLeft(2, "0");
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).abs());
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs());
return "$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
}
@freezed
sealed class CallState with _$CallState {
const factory CallState({
required bool isConnected,
required bool isMicrophoneEnabled,
required bool isCameraEnabled,
required bool isScreenSharing,
required bool isSpeakerphone,
@Default(Duration(seconds: 0)) Duration duration,
@Default(ViewMode.grid) ViewMode viewMode,
String? error,
}) = _CallState;
}
@freezed
sealed class CallParticipantLive with _$CallParticipantLive {
const CallParticipantLive._();
const factory CallParticipantLive({
required CallParticipant participant,
required lk.Participant remoteParticipant,
}) = _CallParticipantLive;
bool get isSpeaking => remoteParticipant.isSpeaking;
bool get isMuted =>
remoteParticipant.isMuted || !remoteParticipant.isMicrophoneEnabled();
bool get isScreenSharing => remoteParticipant.isScreenShareEnabled();
bool get isScreenSharingWithAudio =>
remoteParticipant.isScreenShareAudioEnabled();
bool get hasVideo => remoteParticipant.hasVideo;
bool get hasAudio => remoteParticipant.hasAudio;
}
@Riverpod(keepAlive: true)
class CallNotifier extends _$CallNotifier {
lk.Room? _room;
lk.LocalParticipant? _localParticipant;
List<CallParticipantLive> _participants = [];
final Map<String, CallParticipant> _participantInfoByIdentity = {};
lk.EventsListener? _roomListener;
List<CallParticipantLive> get participants =>
List.unmodifiable(_participants);
lk.LocalParticipant? get localParticipant => _localParticipant;
Map<String, double> participantsVolumes = {};
Timer? _durationTimer;
lk.Room? get room => _room;
@override
CallState build() {
// Subscribe to websocket updates
return const CallState(
isConnected: false,
isMicrophoneEnabled: true,
isCameraEnabled: false,
isScreenSharing: false,
isSpeakerphone: true,
viewMode: ViewMode.grid,
);
}
void _initRoomListeners() {
if (_room == null) return;
_roomListener?.dispose();
_roomListener = _room!.createListener();
_room!.addListener(_onRoomChange);
_roomListener!
..on<lk.ParticipantConnectedEvent>((e) {
_refreshLiveParticipants();
})
..on<lk.RoomDisconnectedEvent>((e) {
_participants = [];
state = state.copyWith();
});
}
void _onRoomChange() {
_refreshLiveParticipants();
}
void _refreshLiveParticipants() {
if (_room == null) return;
final remoteParticipants = _room!.remoteParticipants;
_participants = [];
// Add local participant first if available
if (_localParticipant != null) {
final localInfo = _buildParticipant();
_participants.add(
CallParticipantLive(
participant: localInfo,
remoteParticipant: _localParticipant!,
),
);
}
// Add remote participants
_participants.addAll(
remoteParticipants.values.map((remote) {
final match =
_participantInfoByIdentity[remote.identity] ??
CallParticipant(
identity: remote.identity,
name: remote.identity,
joinedAt: DateTime.now(),
);
return CallParticipantLive(
participant: match,
remoteParticipant: remote,
);
}),
);
state = state.copyWith();
}
/// Builds the CallParticipant object for the local participant.
/// Optionally, pass [participants] if you want to prioritize info from the latest list.
CallParticipant _buildParticipant({List<CallParticipant>? participants}) {
if (_localParticipant == null) {
throw StateError('No local participant available');
}
// Prefer info from the latest participants list if available
if (participants != null) {
final idx = participants.indexWhere(
(p) => p.identity == _localParticipant!.identity,
);
if (idx != -1) return participants[idx];
}
// Otherwise, use info from the identity map or fallback to minimal
return _participantInfoByIdentity[_localParticipant!.identity] ??
CallParticipant(
identity: _localParticipant!.identity,
name: _localParticipant!.identity,
joinedAt: DateTime.now(),
);
}
void _updateLiveParticipants(List<CallParticipant> participants) {
// Update the info map for lookup
for (final p in participants) {
_participantInfoByIdentity[p.identity] = p;
}
if (_room == null) {
// Can't build live objects, just store empty
_participants = [];
state = state.copyWith();
return;
}
final remoteParticipants = _room!.remoteParticipants;
final remotes = remoteParticipants.values.toList();
_participants = [];
// Add local participant if present in the list
if (_localParticipant != null) {
final localInfo = _buildParticipant(participants: participants);
_participants.add(
CallParticipantLive(
participant: localInfo,
remoteParticipant: _localParticipant!,
),
);
state = state.copyWith();
}
// Add remote participants
_participants.addAll(
participants.map((p) {
lk.RemoteParticipant? remote;
for (final r in remotes) {
if (r.identity == p.identity) {
remote = r;
break;
}
}
if (_localParticipant != null &&
p.identity == _localParticipant!.identity) {
return null; // Already added local
}
return remote != null
? CallParticipantLive(participant: p, remoteParticipant: remote)
: null;
}).whereType<CallParticipantLive>(),
);
state = state.copyWith();
}
String? _roomId;
String? get roomId => _roomId;
SnChatRoom? _chatRoom;
SnChatRoom? get chatRoom => _chatRoom;
Future<void> joinRoom(SnChatRoom room) async {
var roomId = room.id;
if (_roomId == roomId &&
_room != null &&
_room?.connectionState == lk.ConnectionState.connected) {
talker.info('[Call] Call skipped. Already has data');
return;
} else if (_room != null) {
if (!_room!.isDisposed &&
_room!.connectionState != lk.ConnectionState.disconnected) {
throw Exception('Call already connected');
}
}
_roomId = roomId;
_chatRoom = room;
if (_room != null) {
await _room!.disconnect();
await _room!.dispose();
_room = null;
_localParticipant = null;
_participants = [];
}
try {
final apiClient = ref.read(apiClientProvider);
final ongoingCall = await ref.read(ongoingCallProvider(roomId).future);
final response = await apiClient.get(
'/messager/chat/realtime/$roomId/join',
);
if (response.statusCode == 200 && response.data != null) {
final data = response.data;
// Parse join response
final joinResponse = ChatRealtimeJoinResponse.fromJson(data);
final participants = joinResponse.participants;
final String endpoint = joinResponse.endpoint;
final String token = joinResponse.token;
// Setup duration timer
_durationTimer?.cancel();
_durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
state = state.copyWith(
duration: Duration(
milliseconds:
(DateTime.now().millisecondsSinceEpoch -
(ongoingCall?.createdAt.millisecondsSinceEpoch ??
DateTime.now().millisecondsSinceEpoch)),
),
);
});
// Connect to LiveKit
_room = lk.Room();
await _room!.connect(
endpoint,
token,
connectOptions: lk.ConnectOptions(autoSubscribe: true),
roomOptions: lk.RoomOptions(adaptiveStream: true, dynacast: true),
fastConnectOptions: lk.FastConnectOptions(
microphone: lk.TrackOption(enabled: true),
),
);
_localParticipant = _room!.localParticipant;
_initRoomListeners();
_updateLiveParticipants(participants);
if (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
lk.Hardware.instance.setSpeakerphoneOn(true);
}
// Listen for connection updates
_room!.addListener(() {
final wasConnected = state.isConnected;
final isNowConnected =
_room!.connectionState == lk.ConnectionState.connected;
state = state.copyWith(
isConnected: isNowConnected,
isMicrophoneEnabled: _localParticipant!.isMicrophoneEnabled(),
isCameraEnabled: _localParticipant!.isCameraEnabled(),
isScreenSharing: _localParticipant!.isScreenShareEnabled(),
);
// Enable wakelock when call connects
if (!wasConnected && isNowConnected) {
WakelockPlus.enable();
}
// Disable wakelock when call disconnects
else if (wasConnected && !isNowConnected) {
WakelockPlus.disable();
}
});
state = state.copyWith(isConnected: true);
// Enable wakelock when call connects
WakelockPlus.enable();
} else {
state = state.copyWith(error: 'Failed to join room');
}
} catch (e) {
state = state.copyWith(error: e.toString());
}
}
Future<void> toggleMicrophone() async {
if (_localParticipant != null) {
const autostop = true;
final target = !_localParticipant!.isMicrophoneEnabled();
state = state.copyWith(isMicrophoneEnabled: target);
if (target) {
await _localParticipant!.audioTrackPublications.firstOrNull?.unmute(
stopOnMute: autostop,
);
} else {
await _localParticipant!.audioTrackPublications.firstOrNull?.mute(
stopOnMute: autostop,
);
}
state = state.copyWith();
}
}
Future<void> toggleCamera() async {
if (_localParticipant != null) {
final target = !_localParticipant!.isCameraEnabled();
state = state.copyWith(isCameraEnabled: target);
await _localParticipant!.setCameraEnabled(target);
state = state.copyWith();
}
}
Future<void> toggleScreenShare(BuildContext context) async {
if (_localParticipant != null) {
final target = !_localParticipant!.isScreenShareEnabled();
state = state.copyWith(isScreenSharing: target);
if (target && lk.lkPlatformIsDesktop()) {
try {
final source = await showDialog<DesktopCapturerSource>(
context: context,
builder: (context) => lk.ScreenSelectDialog(),
);
if (source == null) {
return;
}
var track = await lk.LocalVideoTrack.createScreenShareTrack(
lk.ScreenShareCaptureOptions(
sourceId: source.id,
maxFrameRate: 30.0,
captureScreenAudio: true,
useiOSBroadcastExtension: true,
),
);
await _localParticipant!.publishVideoTrack(track);
} catch (err) {
showErrorAlert(err);
}
return;
} else {
await _localParticipant!.setScreenShareEnabled(target);
}
state = state.copyWith();
}
}
Future<void> toggleSpeakerphone() async {
state = state.copyWith(isSpeakerphone: !state.isSpeakerphone);
await lk.Hardware.instance.setSpeakerphoneOn(state.isSpeakerphone);
state = state.copyWith();
}
Future<void> disconnect() async {
if (_room != null) {
await _room!.disconnect();
state = state.copyWith(
isConnected: false,
isMicrophoneEnabled: false,
isCameraEnabled: false,
isScreenSharing: false,
);
// Disable wakelock when call disconnects
WakelockPlus.disable();
}
}
void setParticipantVolume(CallParticipantLive live, double volume) {
if (participantsVolumes[live.remoteParticipant.sid] == null) {
participantsVolumes[live.remoteParticipant.sid] = 1;
}
Helper.setVolume(
volume,
live
.remoteParticipant
.audioTrackPublications
.first
.track!
.mediaStreamTrack,
);
participantsVolumes[live.remoteParticipant.sid] = volume;
}
double getParticipantVolume(CallParticipantLive live) {
return participantsVolumes[live.remoteParticipant.sid] ?? 1;
}
void toggleViewMode() {
state = state.copyWith(
viewMode: state.viewMode == ViewMode.grid
? ViewMode.stage
: ViewMode.grid,
);
}
void dispose() {
state = state.copyWith(
error: null,
isConnected: false,
isMicrophoneEnabled: false,
isCameraEnabled: false,
isScreenSharing: false,
);
_roomListener?.dispose();
_room?.removeListener(_onRoomChange);
_room?.dispose();
_durationTimer?.cancel();
_roomId = null;
participantsVolumes = {};
// Disable wakelock when disposing
WakelockPlus.disable();
}
}

View File

@@ -0,0 +1,582 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'call.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$CallState implements DiagnosticableTreeMixin {
bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; bool get isSpeakerphone; Duration get duration; ViewMode get viewMode; String? get error;
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>(this as CallState, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'CallState'))
..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('viewMode', viewMode))..add(DiagnosticsProperty('error', error));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.viewMode, viewMode) || other.viewMode == viewMode)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,viewMode,error);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, viewMode: $viewMode, error: $error)';
}
}
/// @nodoc
abstract mixin class $CallStateCopyWith<$Res> {
factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl;
@useResult
$Res call({
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error
});
}
/// @nodoc
class _$CallStateCopyWithImpl<$Res>
implements $CallStateCopyWith<$Res> {
_$CallStateCopyWithImpl(this._self, this._then);
final CallState _self;
final $Res Function(CallState) _then;
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? viewMode = null,Object? error = freezed,}) {
return _then(_self.copyWith(
isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable
as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,viewMode: null == viewMode ? _self.viewMode : viewMode // ignore: cast_nullable_to_non_nullable
as ViewMode,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [CallState].
extension CallStatePatterns on CallState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _CallState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _CallState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _CallState value) $default,){
final _that = this;
switch (_that) {
case _CallState():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _CallState value)? $default,){
final _that = this;
switch (_that) {
case _CallState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _CallState() when $default != null:
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error) $default,) {final _that = this;
switch (_that) {
case _CallState():
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error)? $default,) {final _that = this;
switch (_that) {
case _CallState() when $default != null:
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);case _:
return null;
}
}
}
/// @nodoc
class _CallState with DiagnosticableTreeMixin implements CallState {
const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, required this.isSpeakerphone, this.duration = const Duration(seconds: 0), this.viewMode = ViewMode.grid, this.error});
@override final bool isConnected;
@override final bool isMicrophoneEnabled;
@override final bool isCameraEnabled;
@override final bool isScreenSharing;
@override final bool isSpeakerphone;
@override@JsonKey() final Duration duration;
@override@JsonKey() final ViewMode viewMode;
@override final String? error;
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallState>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'CallState'))
..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('viewMode', viewMode))..add(DiagnosticsProperty('error', error));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.viewMode, viewMode) || other.viewMode == viewMode)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,viewMode,error);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, viewMode: $viewMode, error: $error)';
}
}
/// @nodoc
abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Res> {
factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl;
@override @useResult
$Res call({
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error
});
}
/// @nodoc
class __$CallStateCopyWithImpl<$Res>
implements _$CallStateCopyWith<$Res> {
__$CallStateCopyWithImpl(this._self, this._then);
final _CallState _self;
final $Res Function(_CallState) _then;
/// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? viewMode = null,Object? error = freezed,}) {
return _then(_CallState(
isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable
as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,viewMode: null == viewMode ? _self.viewMode : viewMode // ignore: cast_nullable_to_non_nullable
as ViewMode,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
mixin _$CallParticipantLive implements DiagnosticableTreeMixin {
CallParticipant get participant; lk.Participant get remoteParticipant;
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$CallParticipantLiveCopyWith<CallParticipantLive> get copyWith => _$CallParticipantLiveCopyWithImpl<CallParticipantLive>(this as CallParticipantLive, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'CallParticipantLive'))
..add(DiagnosticsProperty('participant', participant))..add(DiagnosticsProperty('remoteParticipant', remoteParticipant));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipantLive&&(identical(other.participant, participant) || other.participant == participant)&&(identical(other.remoteParticipant, remoteParticipant) || other.remoteParticipant == remoteParticipant));
}
@override
int get hashCode => Object.hash(runtimeType,participant,remoteParticipant);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)';
}
}
/// @nodoc
abstract mixin class $CallParticipantLiveCopyWith<$Res> {
factory $CallParticipantLiveCopyWith(CallParticipantLive value, $Res Function(CallParticipantLive) _then) = _$CallParticipantLiveCopyWithImpl;
@useResult
$Res call({
CallParticipant participant, lk.Participant remoteParticipant
});
$CallParticipantCopyWith<$Res> get participant;
}
/// @nodoc
class _$CallParticipantLiveCopyWithImpl<$Res>
implements $CallParticipantLiveCopyWith<$Res> {
_$CallParticipantLiveCopyWithImpl(this._self, this._then);
final CallParticipantLive _self;
final $Res Function(CallParticipantLive) _then;
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? participant = null,Object? remoteParticipant = null,}) {
return _then(_self.copyWith(
participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable
as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable
as lk.Participant,
));
}
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$CallParticipantCopyWith<$Res> get participant {
return $CallParticipantCopyWith<$Res>(_self.participant, (value) {
return _then(_self.copyWith(participant: value));
});
}
}
/// Adds pattern-matching-related methods to [CallParticipantLive].
extension CallParticipantLivePatterns on CallParticipantLive {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _CallParticipantLive value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _CallParticipantLive() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _CallParticipantLive value) $default,){
final _that = this;
switch (_that) {
case _CallParticipantLive():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _CallParticipantLive value)? $default,){
final _that = this;
switch (_that) {
case _CallParticipantLive() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( CallParticipant participant, lk.Participant remoteParticipant)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _CallParticipantLive() when $default != null:
return $default(_that.participant,_that.remoteParticipant);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( CallParticipant participant, lk.Participant remoteParticipant) $default,) {final _that = this;
switch (_that) {
case _CallParticipantLive():
return $default(_that.participant,_that.remoteParticipant);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( CallParticipant participant, lk.Participant remoteParticipant)? $default,) {final _that = this;
switch (_that) {
case _CallParticipantLive() when $default != null:
return $default(_that.participant,_that.remoteParticipant);case _:
return null;
}
}
}
/// @nodoc
class _CallParticipantLive extends CallParticipantLive with DiagnosticableTreeMixin {
const _CallParticipantLive({required this.participant, required this.remoteParticipant}): super._();
@override final CallParticipant participant;
@override final lk.Participant remoteParticipant;
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$CallParticipantLiveCopyWith<_CallParticipantLive> get copyWith => __$CallParticipantLiveCopyWithImpl<_CallParticipantLive>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'CallParticipantLive'))
..add(DiagnosticsProperty('participant', participant))..add(DiagnosticsProperty('remoteParticipant', remoteParticipant));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipantLive&&(identical(other.participant, participant) || other.participant == participant)&&(identical(other.remoteParticipant, remoteParticipant) || other.remoteParticipant == remoteParticipant));
}
@override
int get hashCode => Object.hash(runtimeType,participant,remoteParticipant);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)';
}
}
/// @nodoc
abstract mixin class _$CallParticipantLiveCopyWith<$Res> implements $CallParticipantLiveCopyWith<$Res> {
factory _$CallParticipantLiveCopyWith(_CallParticipantLive value, $Res Function(_CallParticipantLive) _then) = __$CallParticipantLiveCopyWithImpl;
@override @useResult
$Res call({
CallParticipant participant, lk.Participant remoteParticipant
});
@override $CallParticipantCopyWith<$Res> get participant;
}
/// @nodoc
class __$CallParticipantLiveCopyWithImpl<$Res>
implements _$CallParticipantLiveCopyWith<$Res> {
__$CallParticipantLiveCopyWithImpl(this._self, this._then);
final _CallParticipantLive _self;
final $Res Function(_CallParticipantLive) _then;
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? participant = null,Object? remoteParticipant = null,}) {
return _then(_CallParticipantLive(
participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable
as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable
as lk.Participant,
));
}
/// Create a copy of CallParticipantLive
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$CallParticipantCopyWith<$Res> get participant {
return $CallParticipantCopyWith<$Res>(_self.participant, (value) {
return _then(_self.copyWith(participant: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'call.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(CallNotifier)
final callProvider = CallNotifierProvider._();
final class CallNotifierProvider
extends $NotifierProvider<CallNotifier, CallState> {
CallNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'callProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$callNotifierHash();
@$internal
@override
CallNotifier create() => CallNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CallState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CallState>(value),
);
}
}
String _$callNotifierHash() => r'caa03913d98c6d98448af44059db5ef72b5d58f6';
abstract class _$CallNotifier extends $Notifier<CallState> {
CallState build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<CallState, CallState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<CallState, CallState>,
CallState,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/core/network.dart';
import 'package:island/core/websocket.dart';
import 'package:island/accounts/accounts_models/account.dart';
part 'chat_online_count.g.dart';
@riverpod
class ChatOnlineCountNotifier extends _$ChatOnlineCountNotifier {
@override
Future<int> build(String chatroomId) async {
final apiClient = ref.watch(apiClientProvider);
final ws = ref.watch(websocketProvider);
// Fetch initial online count
final response = await apiClient.get(
'/messager/chat/$chatroomId/members/online',
);
final initialCount = response.data as int;
// Listen for websocket status updates
final subscription = ws.dataStream.listen((WebSocketPacket packet) {
if (packet.type == 'accounts.status.update') {
final data = packet.data;
if (data != null && data['chat_room_id'] == chatroomId) {
final status = SnAccountStatus.fromJson(data['status']);
var delta = status.isOnline ? 1 : -1;
if (status.clearedAt != null &&
status.clearedAt!.isBefore(DateTime.now())) {
if (status.isInvisible) delta = 1;
}
// Update count based on online status
state.whenData((currentCount) {
final newCount = currentCount + delta;
state = AsyncData(
newCount.clamp(0, double.infinity).toInt(),
); // Ensure non-negative
});
}
}
});
ref.onDispose(() {
subscription.cancel();
});
return initialCount;
}
}

View File

@@ -0,0 +1,101 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_online_count.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatOnlineCountNotifier)
final chatOnlineCountProvider = ChatOnlineCountNotifierFamily._();
final class ChatOnlineCountNotifierProvider
extends $AsyncNotifierProvider<ChatOnlineCountNotifier, int> {
ChatOnlineCountNotifierProvider._({
required ChatOnlineCountNotifierFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'chatOnlineCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatOnlineCountNotifierHash();
@override
String toString() {
return r'chatOnlineCountProvider'
''
'($argument)';
}
@$internal
@override
ChatOnlineCountNotifier create() => ChatOnlineCountNotifier();
@override
bool operator ==(Object other) {
return other is ChatOnlineCountNotifierProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatOnlineCountNotifierHash() =>
r'b2f9f17bfece1937ec90590b8f11db2bec923156';
final class ChatOnlineCountNotifierFamily extends $Family
with
$ClassFamilyOverride<
ChatOnlineCountNotifier,
AsyncValue<int>,
int,
FutureOr<int>,
String
> {
ChatOnlineCountNotifierFamily._()
: super(
retry: null,
name: r'chatOnlineCountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatOnlineCountNotifierProvider call(String chatroomId) =>
ChatOnlineCountNotifierProvider._(argument: chatroomId, from: this);
@override
String toString() => r'chatOnlineCountProvider';
}
abstract class _$ChatOnlineCountNotifier extends $AsyncNotifier<int> {
late final _$args = ref.$arg as String;
String get chatroomId => _$args;
FutureOr<int> build(String chatroomId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<int>, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<int>, int>,
AsyncValue<int>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}

View File

@@ -0,0 +1,452 @@
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/data/drift_db.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/realms/realms_models/realm.dart';
import 'package:island/core/database.dart';
import 'package:island/core/network.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_room.g.dart';
final chatSyncingProvider = NotifierProvider<ChatSyncingNotifier, bool>(
ChatSyncingNotifier.new,
);
class ChatSyncingNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool value) => state = value;
}
final flashingMessagesProvider =
NotifierProvider<FlashingMessagesNotifier, Set<String>>(
FlashingMessagesNotifier.new,
);
class FlashingMessagesNotifier extends Notifier<Set<String>> {
@override
Set<String> build() => {};
void update(Set<String> Function(Set<String>) cb) {
state = cb(state);
}
void clear() => state = {};
}
@riverpod
class ChatRoomJoinedNotifier extends _$ChatRoomJoinedNotifier {
@override
Future<List<SnChatRoom>> build() async {
final db = ref.watch(databaseProvider);
try {
final localRoomsData = await db.select(db.chatRooms).get();
final localRealmsData = await db.select(db.realms).get();
if (localRoomsData.isNotEmpty) {
final localRooms = await Future.wait(
localRoomsData.map((row) async {
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(row.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
return SnChatRoom(
id: row.id,
name: row.name,
description: row.description,
type: row.type,
isPublic: row.isPublic!,
isCommunity: row.isCommunity!,
picture: row.picture != null
? SnCloudFile.fromJson(row.picture!)
: null,
background: row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
realmId: row.realmId,
accountId: row.accountId,
realm: localRealmsData
.where((e) => e.id == row.realmId)
.map((e) => _buildRealmFromTableEntry(e))
.firstOrNull,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
members: members,
isPinned: row.isPinned ?? false,
);
}),
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get('/messager/chat');
final remoteRooms = resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(remoteRooms, override: true);
// Update state with fresh data
state = AsyncData(await _buildRoomsFromDb(db));
} catch (_) {}
}).ignore();
return localRooms;
}
} catch (_) {}
// Fallback to API
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat');
final rooms = resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(rooms, override: true);
return rooms;
}
SnRealm _buildRealmFromTableEntry(Realm localRealm) {
return SnRealm(
id: localRealm.id,
slug: localRealm.slug,
name: localRealm.name ?? localRealm.slug,
description: localRealm.description ?? '',
verifiedAs: localRealm.verifiedAs,
verifiedAt: localRealm.verifiedAt,
isCommunity: localRealm.isCommunity,
isPublic: localRealm.isPublic,
picture: localRealm.picture != null
? SnCloudFile.fromJson(localRealm.picture!)
: null,
background: localRealm.background != null
? SnCloudFile.fromJson(localRealm.background!)
: null,
accountId: localRealm.accountId ?? '',
createdAt: localRealm.createdAt,
updatedAt: localRealm.updatedAt,
deletedAt: localRealm.deletedAt,
);
}
Future<List<SnChatRoom>> _buildRoomsFromDb(AppDatabase db) async {
final localRoomsData = await db.select(db.chatRooms).get();
return Future.wait(
localRoomsData.map((row) async {
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(row.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
// Load realm if it exists
SnRealm? realm;
if (row.realmId != null) {
try {
final realmRow = await (db.select(
db.realms,
)..where((r) => r.id.equals(row.realmId!))).getSingleOrNull();
if (realmRow != null) {
realm = SnRealm(
id: realmRow.id,
slug: '', // Not stored in DB
name: realmRow.name ?? '',
description: realmRow.description ?? '',
verifiedAs: null, // Not stored in DB
verifiedAt: null, // Not stored in DB
isCommunity: false, // Not stored in DB
isPublic: true, // Not stored in DB
picture: realmRow.picture != null
? SnCloudFile.fromJson(realmRow.picture!)
: null,
background: realmRow.background != null
? SnCloudFile.fromJson(realmRow.background!)
: null,
accountId: realmRow.accountId ?? '',
createdAt: realmRow.createdAt,
updatedAt: realmRow.updatedAt,
deletedAt: realmRow.deletedAt,
);
}
} catch (_) {
// Realm not found, keep as null
}
}
return SnChatRoom(
id: row.id,
name: row.name,
description: row.description,
type: row.type,
isPublic: row.isPublic!,
isCommunity: row.isCommunity!,
picture: row.picture != null
? SnCloudFile.fromJson(row.picture!)
: null,
background: row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
realmId: row.realmId,
accountId: row.accountId,
realm: realm,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
members: members,
isPinned: row.isPinned ?? false,
);
}),
);
}
}
@riverpod
class ChatRoomNotifier extends _$ChatRoomNotifier {
@override
Future<SnChatRoom?> build(String? identifier) async {
if (identifier == null) return null;
final db = ref.watch(databaseProvider);
try {
// Try to get from local database first
final localRoomData = await (db.select(
db.chatRooms,
)..where((r) => r.id.equals(identifier))).getSingleOrNull();
if (localRoomData != null) {
// Fetch members for this room
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(localRoomData.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
final localRoom = SnChatRoom(
id: localRoomData.id,
name: localRoomData.name,
description: localRoomData.description,
type: localRoomData.type,
isPublic: localRoomData.isPublic!,
isCommunity: localRoomData.isCommunity!,
picture: localRoomData.picture != null
? SnCloudFile.fromJson(localRoomData.picture!)
: null,
background: localRoomData.background != null
? SnCloudFile.fromJson(localRoomData.background!)
: null,
realmId: localRoomData.realmId,
accountId: localRoomData.accountId,
realm: null,
createdAt: localRoomData.createdAt,
updatedAt: localRoomData.updatedAt,
deletedAt: localRoomData.deletedAt,
members: members,
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get('/messager/chat/$identifier');
final remoteRoom = SnChatRoom.fromJson(resp.data);
// Update state with fresh data directly without saving to DB
// DB will be updated by ChatRoomJoinedNotifier's full sync
state = AsyncData(remoteRoom);
} catch (_) {}
}).ignore();
return localRoom;
}
} catch (_) {}
// Fallback to API
try {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat/$identifier');
final room = SnChatRoom.fromJson(resp.data);
await db.saveChatRooms([room]);
return room;
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // Chat room not found
}
rethrow; // Rethrow other errors
}
}
}
@riverpod
class ChatRoomIdentityNotifier extends _$ChatRoomIdentityNotifier {
@override
Future<SnChatMember?> build(String? identifier) async {
if (identifier == null) return null;
final db = ref.watch(databaseProvider);
final userInfo = ref.watch(userInfoProvider);
try {
// Try to get from local database first
if (userInfo.value != null) {
final localMemberData =
await (db.select(db.chatMembers)
..where((m) => m.chatRoomId.equals(identifier))
..where((m) => m.accountId.equals(userInfo.value!.id)))
.getSingleOrNull();
if (localMemberData != null) {
final account = SnAccount.fromJson(localMemberData.account);
final localMember = SnChatMember(
id: localMemberData.id,
chatRoomId: localMemberData.chatRoomId,
accountId: localMemberData.accountId,
account: account,
nick: localMemberData.nick,
notify: localMemberData.notify,
joinedAt: localMemberData.joinedAt,
breakUntil: localMemberData.breakUntil,
timeoutUntil: localMemberData.timeoutUntil,
status: null,
createdAt: localMemberData.createdAt,
updatedAt: localMemberData.updatedAt,
deletedAt: localMemberData.deletedAt,
chatRoom: null,
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get(
'/messager/chat/$identifier/members/me',
);
final remoteMember = SnChatMember.fromJson(resp.data);
await db.saveMember(remoteMember);
// Update state with fresh data
if (userInfo.value != null) {
state = AsyncData(
await _buildMemberFromDb(db, identifier, userInfo.value!.id),
);
}
} catch (_) {}
}).ignore();
return localMember;
}
}
} catch (_) {}
// Fallback to API
try {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat/$identifier/members/me');
final member = SnChatMember.fromJson(resp.data);
await db.saveMember(member);
return member;
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // Chat member not found
}
rethrow; // Rethrow other errors
}
}
Future<SnChatMember?> _buildMemberFromDb(
AppDatabase db,
String identifier,
String accountId,
) async {
final localMemberData =
await (db.select(db.chatMembers)
..where((m) => m.chatRoomId.equals(identifier))
..where((m) => m.accountId.equals(accountId)))
.getSingleOrNull();
if (localMemberData == null) return null;
final account = SnAccount.fromJson(localMemberData.account);
return SnChatMember(
id: localMemberData.id,
chatRoomId: localMemberData.chatRoomId,
accountId: localMemberData.accountId,
account: account,
nick: localMemberData.nick,
notify: localMemberData.notify,
joinedAt: localMemberData.joinedAt,
breakUntil: localMemberData.breakUntil,
timeoutUntil: localMemberData.timeoutUntil,
status: null,
createdAt: localMemberData.createdAt,
updatedAt: localMemberData.updatedAt,
deletedAt: localMemberData.deletedAt,
chatRoom: null,
);
}
}
@riverpod
Future<List<SnChatMember>> chatroomInvites(Ref ref) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat/invites');
return resp.data
.map((e) => SnChatMember.fromJson(e))
.cast<SnChatMember>()
.toList();
}

View File

@@ -0,0 +1,279 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_room.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatRoomJoinedNotifier)
final chatRoomJoinedProvider = ChatRoomJoinedNotifierProvider._();
final class ChatRoomJoinedNotifierProvider
extends $AsyncNotifierProvider<ChatRoomJoinedNotifier, List<SnChatRoom>> {
ChatRoomJoinedNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatRoomJoinedProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatRoomJoinedNotifierHash();
@$internal
@override
ChatRoomJoinedNotifier create() => ChatRoomJoinedNotifier();
}
String _$chatRoomJoinedNotifierHash() =>
r'b3726e10298b99a8529c5e28a5c402b95016f096';
abstract class _$ChatRoomJoinedNotifier
extends $AsyncNotifier<List<SnChatRoom>> {
FutureOr<List<SnChatRoom>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<List<SnChatRoom>>, List<SnChatRoom>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<SnChatRoom>>, List<SnChatRoom>>,
AsyncValue<List<SnChatRoom>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(ChatRoomNotifier)
final chatRoomProvider = ChatRoomNotifierFamily._();
final class ChatRoomNotifierProvider
extends $AsyncNotifierProvider<ChatRoomNotifier, SnChatRoom?> {
ChatRoomNotifierProvider._({
required ChatRoomNotifierFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'chatRoomProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatRoomNotifierHash();
@override
String toString() {
return r'chatRoomProvider'
''
'($argument)';
}
@$internal
@override
ChatRoomNotifier create() => ChatRoomNotifier();
@override
bool operator ==(Object other) {
return other is ChatRoomNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatRoomNotifierHash() => r'9f7a8bdd4af381c6b60e65e74363a0af3c1a650e';
final class ChatRoomNotifierFamily extends $Family
with
$ClassFamilyOverride<
ChatRoomNotifier,
AsyncValue<SnChatRoom?>,
SnChatRoom?,
FutureOr<SnChatRoom?>,
String?
> {
ChatRoomNotifierFamily._()
: super(
retry: null,
name: r'chatRoomProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatRoomNotifierProvider call(String? identifier) =>
ChatRoomNotifierProvider._(argument: identifier, from: this);
@override
String toString() => r'chatRoomProvider';
}
abstract class _$ChatRoomNotifier extends $AsyncNotifier<SnChatRoom?> {
late final _$args = ref.$arg as String?;
String? get identifier => _$args;
FutureOr<SnChatRoom?> build(String? identifier);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<SnChatRoom?>, SnChatRoom?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SnChatRoom?>, SnChatRoom?>,
AsyncValue<SnChatRoom?>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(ChatRoomIdentityNotifier)
final chatRoomIdentityProvider = ChatRoomIdentityNotifierFamily._();
final class ChatRoomIdentityNotifierProvider
extends $AsyncNotifierProvider<ChatRoomIdentityNotifier, SnChatMember?> {
ChatRoomIdentityNotifierProvider._({
required ChatRoomIdentityNotifierFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'chatRoomIdentityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatRoomIdentityNotifierHash();
@override
String toString() {
return r'chatRoomIdentityProvider'
''
'($argument)';
}
@$internal
@override
ChatRoomIdentityNotifier create() => ChatRoomIdentityNotifier();
@override
bool operator ==(Object other) {
return other is ChatRoomIdentityNotifierProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatRoomIdentityNotifierHash() =>
r'1ce75462a19cc037c97ee6084a30fee1f5335875';
final class ChatRoomIdentityNotifierFamily extends $Family
with
$ClassFamilyOverride<
ChatRoomIdentityNotifier,
AsyncValue<SnChatMember?>,
SnChatMember?,
FutureOr<SnChatMember?>,
String?
> {
ChatRoomIdentityNotifierFamily._()
: super(
retry: null,
name: r'chatRoomIdentityProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatRoomIdentityNotifierProvider call(String? identifier) =>
ChatRoomIdentityNotifierProvider._(argument: identifier, from: this);
@override
String toString() => r'chatRoomIdentityProvider';
}
abstract class _$ChatRoomIdentityNotifier
extends $AsyncNotifier<SnChatMember?> {
late final _$args = ref.$arg as String?;
String? get identifier => _$args;
FutureOr<SnChatMember?> build(String? identifier);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<SnChatMember?>, SnChatMember?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SnChatMember?>, SnChatMember?>,
AsyncValue<SnChatMember?>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(chatroomInvites)
final chatroomInvitesProvider = ChatroomInvitesProvider._();
final class ChatroomInvitesProvider
extends
$FunctionalProvider<
AsyncValue<List<SnChatMember>>,
List<SnChatMember>,
FutureOr<List<SnChatMember>>
>
with
$FutureModifier<List<SnChatMember>>,
$FutureProvider<List<SnChatMember>> {
ChatroomInvitesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatroomInvitesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatroomInvitesHash();
@$internal
@override
$FutureProviderElement<List<SnChatMember>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnChatMember>> create(Ref ref) {
return chatroomInvites(ref);
}
}
String _$chatroomInvitesHash() => r'fc23231d5f111b1c3796ffae2b471384b951861a';

View File

@@ -0,0 +1,294 @@
import "dart:async";
import "dart:convert";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:island/chat/chat_widgets/call_button.dart";
import "package:island/chat/messages_notifier.dart";
import "package:just_audio/just_audio.dart";
import "package:island/core/config.dart";
import "package:island/chat/chat_models/chat.dart";
import "package:island/chat/chat_pod/chat_room.dart";
import "package:island/core/lifecycle.dart";
import "package:island/core/websocket.dart";
import "package:island/talker.dart";
import "package:riverpod_annotation/riverpod_annotation.dart";
part 'chat_subscribe.g.dart';
final currentSubscribedChatIdProvider =
NotifierProvider<CurrentSubscribedChatIdNotifier, String?>(
CurrentSubscribedChatIdNotifier.new,
);
class CurrentSubscribedChatIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) => state = value;
}
@riverpod
class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
late SnChatRoom _chatRoom;
late SnChatMember _chatIdentity;
late MessagesNotifier _messagesNotifier;
final List<SnChatMember> _typingStatuses = [];
Timer? _typingCleanupTimer;
Timer? _typingCooldownTimer;
Timer? _periodicSubscribeTimer;
StreamSubscription? _wsSubscription;
Function? _sendMessage;
void _cleanupResources() {
if (_wsSubscription != null) {
_wsSubscription!.cancel();
_wsSubscription = null;
}
if (_typingCleanupTimer != null) {
_typingCleanupTimer!.cancel();
_typingCleanupTimer = null;
}
if (_periodicSubscribeTimer != null) {
_periodicSubscribeTimer!.cancel();
_periodicSubscribeTimer = null;
}
}
@override
List<SnChatMember> build(String roomId) {
final ws = ref.watch(websocketProvider);
final chatRoomAsync = ref.watch(chatRoomProvider(roomId));
final chatIdentityAsync = ref.watch(chatRoomIdentityProvider(roomId));
_messagesNotifier = ref.watch(messagesProvider(roomId).notifier);
_cleanupResources();
if (chatRoomAsync.isLoading || chatIdentityAsync.isLoading) {
return [];
}
if (chatRoomAsync.value == null || chatIdentityAsync.value == null) {
return [];
}
_chatRoom = chatRoomAsync.value!;
_chatIdentity = chatIdentityAsync.value!;
// Subscribe to messages
final wsState = ref.read(websocketStateProvider.notifier);
_sendMessage = wsState.sendMessage;
talker.info('[MessageSubscriber] Subscribing room $roomId');
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
Future.microtask(
() => ref.read(currentSubscribedChatIdProvider.notifier).set(roomId),
);
// Send initial read receipt
sendReadReceipt();
// Set up WebSocket listener
_wsSubscription = ws.dataStream.listen(onMessage);
// Set up typing status cleanup timer
_typingCleanupTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (_typingStatuses.isNotEmpty) {
// Remove typing statuses older than 5 seconds
final now = DateTime.now();
_typingStatuses.removeWhere((member) {
final lastTyped =
member.lastTyped ??
DateTime.now().subtract(const Duration(milliseconds: 1350));
return now.difference(lastTyped).inSeconds > 5;
});
state = List.of(_typingStatuses);
}
});
// Set up periodic subscribe timer (every 5 minutes)
_periodicSubscribeTimer = Timer.periodic(const Duration(minutes: 5), (_) {
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
});
ref.listen(appLifecycleStateProvider, (previous, next) {
final lifecycleState = next.value;
if (lifecycleState == AppLifecycleState.paused ||
lifecycleState == AppLifecycleState.inactive) {
// Unsubscribe when app goes to background
talker.info('[MessageSubscriber] Unsubscribing room $roomId');
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
} else if (lifecycleState == AppLifecycleState.resumed) {
// Resubscribe when app comes back to foreground
talker.info('[MessageSubscriber] Subscribing room $roomId');
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
}
});
final subscribedNotifier = ref.watch(
currentSubscribedChatIdProvider.notifier,
);
ref.onCancel(() {
talker.info('[MessageSubscriber] Unsubscribing room $roomId');
subscribedNotifier.set(null);
try {
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
} catch (e, stackTrace) {
talker.error(
'[MessageSubscriber] Error sending unsubscribe message for room $roomId: $e\n$stackTrace',
);
}
try {
_cleanupResources();
} catch (e, stackTrace) {
talker.error(
'[MessageSubscriber] Error during cleanup for room $roomId: $e\n$stackTrace',
);
}
try {
if (_typingCooldownTimer != null) {
_typingCooldownTimer!.cancel();
}
} catch (e, stackTrace) {
talker.error(
'[MessageSubscriber] Error cancelling typing cooldown timer for room $roomId: $e\n$stackTrace',
);
}
});
return _typingStatuses;
}
Future<void> onMessage(WebSocketPacket pkt) async {
if (!pkt.type.startsWith('messages')) return;
if (['messages.read'].contains(pkt.type)) return;
if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) {
if (pkt.data?['room_id'] != _chatRoom.id) return;
if (pkt.data?['sender_id'] == _chatIdentity.id) return;
final sender = SnChatMember.fromJson(
pkt.data?['sender'],
).copyWith(lastTyped: DateTime.now());
// Check if the sender is already in the typing list
final existingIndex = _typingStatuses.indexWhere(
(member) => member.id == sender.id,
);
if (existingIndex >= 0) {
// Update the existing entry with new timestamp
_typingStatuses[existingIndex] = sender;
} else {
// Add new typing status
_typingStatuses.add(sender);
}
if (ref.mounted) state = List.of(_typingStatuses);
return;
}
final message = SnChatMessage.fromJson(pkt.data!);
if (message.chatRoomId != _chatRoom.id) return;
switch (pkt.type) {
case 'messages.new':
case 'messages.update':
case 'messages.delete':
if (message.type.startsWith('call')) {
// Handle the ongoing call.
ref.invalidate(ongoingCallProvider(message.chatRoomId));
}
_messagesNotifier.receiveMessage(message);
// Send read receipt for new message
sendReadReceipt();
// Play sound for new messages when app is unfocused
if (pkt.type == 'messages.new' &&
message.senderId != _chatIdentity.id &&
ref.read(appLifecycleStateProvider).value !=
AppLifecycleState.resumed &&
ref.read(appSettingsProvider).soundEffects) {
final player = AudioPlayer();
await player.setVolume(0.75);
await player.setAudioSource(
AudioSource.asset('assets/audio/messages.mp3'),
);
await player.play();
player.dispose();
}
}
}
void sendReadReceipt() {
// Send websocket packet
if (_sendMessage == null) return;
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.read',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
}
void sendTypingStatus() {
// Don't send if we're already in a cooldown period
if (_typingCooldownTimer != null) return;
// Send typing status immediately
if (_sendMessage == null) return;
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.typing',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
_typingCooldownTimer = Timer(const Duration(milliseconds: 850), () {
_typingCooldownTimer = null;
});
}
}

View File

@@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_subscribe.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatSubscribeNotifier)
final chatSubscribeProvider = ChatSubscribeNotifierFamily._();
final class ChatSubscribeNotifierProvider
extends $NotifierProvider<ChatSubscribeNotifier, List<SnChatMember>> {
ChatSubscribeNotifierProvider._({
required ChatSubscribeNotifierFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'chatSubscribeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatSubscribeNotifierHash();
@override
String toString() {
return r'chatSubscribeProvider'
''
'($argument)';
}
@$internal
@override
ChatSubscribeNotifier create() => ChatSubscribeNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<SnChatMember> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<SnChatMember>>(value),
);
}
@override
bool operator ==(Object other) {
return other is ChatSubscribeNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatSubscribeNotifierHash() =>
r'f2f5059a975fc44a41850459d6b7d041ff9d41cb';
final class ChatSubscribeNotifierFamily extends $Family
with
$ClassFamilyOverride<
ChatSubscribeNotifier,
List<SnChatMember>,
List<SnChatMember>,
List<SnChatMember>,
String
> {
ChatSubscribeNotifierFamily._()
: super(
retry: null,
name: r'chatSubscribeProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatSubscribeNotifierProvider call(String roomId) =>
ChatSubscribeNotifierProvider._(argument: roomId, from: this);
@override
String toString() => r'chatSubscribeProvider';
}
abstract class _$ChatSubscribeNotifier extends $Notifier<List<SnChatMember>> {
late final _$args = ref.$arg as String;
String get roomId => _$args;
List<SnChatMember> build(String roomId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<List<SnChatMember>, List<SnChatMember>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<SnChatMember>, List<SnChatMember>>,
List<SnChatMember>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/core/network.dart';
import 'package:island/core/websocket.dart';
import 'package:island/chat/chat_pod/chat_subscribe.dart';
part 'chat_summary.g.dart';
@riverpod
class ChatUnreadCountNotifier extends _$ChatUnreadCountNotifier {
StreamSubscription<WebSocketPacket>? _subscription;
@override
Future<int> build() async {
// Subscribe to websocket events when this provider is built
_subscribeToWebSocket();
// Dispose the subscription when this provider is disposed
ref.onDispose(() {
_subscription?.cancel();
});
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/messager/chat/unread');
return (response.data as num).toInt();
} catch (_) {
return 0;
}
}
void _subscribeToWebSocket() {
final webSocketService = ref.read(websocketProvider);
_subscription = webSocketService.dataStream.listen((packet) {
if (packet.type == 'messages.new' && packet.data != null) {
final message = SnChatMessage.fromJson(packet.data!);
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
// Only increment if the message is not from the currently subscribed chat
if (message.chatRoomId != currentSubscribed) {
_incrementCounter();
}
}
});
}
Future<void> _incrementCounter() async {
final current = await future;
state = AsyncData(current + 1);
}
Future<void> decrement(int count) async {
final current = await future;
state = AsyncData(math.max(current - count, 0));
}
void clear() async {
state = AsyncData(0);
}
}
@Riverpod(keepAlive: true)
class ChatSummary extends _$ChatSummary {
@override
Future<Map<String, SnChatSummary>> build() async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat/summary');
final Map<String, dynamic> data = resp.data;
final summaries = data.map(
(key, value) => MapEntry(key, SnChatSummary.fromJson(value)),
);
final ws = ref.watch(websocketProvider);
final subscription = ws.dataStream.listen((WebSocketPacket pkt) {
if (!pkt.type.startsWith('messages')) return;
if (pkt.type == 'messages.new') {
final message = SnChatMessage.fromJson(pkt.data!);
updateLastMessage(message.chatRoomId, message);
} else if (pkt.type == 'messages.update') {
final message = SnChatMessage.fromJson(pkt.data!);
updateMessageContent(message.chatRoomId, message);
}
});
ref.onDispose(() {
subscription.cancel();
});
return summaries;
}
Future<void> clearUnreadCount(String chatId) async {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null) {
// Decrement global unread count
final unreadToDecrement = summary.unreadCount;
if (unreadToDecrement > 0) {
ref
.read(chatUnreadCountProvider.notifier)
.decrement(unreadToDecrement);
}
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) {
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
final increment = (chatId != currentSubscribed) ? 1 : 0;
state = AsyncData({
...summaries,
chatId: SnChatSummary(
unreadCount: summary.unreadCount + increment,
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,
),
});
}
});
}
void updateMessageContent(String chatId, SnChatMessage message) {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null && summary.lastMessage?.id == message.id) {
state = AsyncData({
...summaries,
chatId: SnChatSummary(
unreadCount: summary.unreadCount,
lastMessage: message,
),
});
}
});
}
}

View File

@@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_summary.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatUnreadCountNotifier)
final chatUnreadCountProvider = ChatUnreadCountNotifierProvider._();
final class ChatUnreadCountNotifierProvider
extends $AsyncNotifierProvider<ChatUnreadCountNotifier, int> {
ChatUnreadCountNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatUnreadCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatUnreadCountNotifierHash();
@$internal
@override
ChatUnreadCountNotifier create() => ChatUnreadCountNotifier();
}
String _$chatUnreadCountNotifierHash() =>
r'169b28f8759ebd9de75f7de17f60d493737ee7a8';
abstract class _$ChatUnreadCountNotifier extends $AsyncNotifier<int> {
FutureOr<int> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<int>, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<int>, int>,
AsyncValue<int>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(ChatSummary)
final chatSummaryProvider = ChatSummaryProvider._();
final class ChatSummaryProvider
extends $AsyncNotifierProvider<ChatSummary, Map<String, SnChatSummary>> {
ChatSummaryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatSummaryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatSummaryHash();
@$internal
@override
ChatSummary create() => ChatSummary();
}
String _$chatSummaryHash() => r'82f516d4ce8b67dadb815523df57a3c30a33ef91';
abstract class _$ChatSummary
extends $AsyncNotifier<Map<String, SnChatSummary>> {
FutureOr<Map<String, SnChatSummary>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
AsyncValue<Map<String, SnChatSummary>>,
Map<String, SnChatSummary>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<Map<String, SnChatSummary>>,
Map<String, SnChatSummary>
>,
AsyncValue<Map<String, SnChatSummary>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

452
lib/chat/chat_room.dart Normal file
View File

@@ -0,0 +1,452 @@
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/data/drift_db.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/realms/realms_models/realm.dart';
import 'package:island/core/database.dart';
import 'package:island/core/network.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_room.g.dart';
final chatSyncingProvider = NotifierProvider<ChatSyncingNotifier, bool>(
ChatSyncingNotifier.new,
);
class ChatSyncingNotifier extends Notifier<bool> {
@override
bool build() => false;
void set(bool value) => state = value;
}
final flashingMessagesProvider =
NotifierProvider<FlashingMessagesNotifier, Set<String>>(
FlashingMessagesNotifier.new,
);
class FlashingMessagesNotifier extends Notifier<Set<String>> {
@override
Set<String> build() => {};
void update(Set<String> Function(Set<String>) cb) {
state = cb(state);
}
void clear() => state = {};
}
@riverpod
class ChatRoomJoinedNotifier extends _$ChatRoomJoinedNotifier {
@override
Future<List<SnChatRoom>> build() async {
final db = ref.watch(databaseProvider);
try {
final localRoomsData = await db.select(db.chatRooms).get();
final localRealmsData = await db.select(db.realms).get();
if (localRoomsData.isNotEmpty) {
final localRooms = await Future.wait(
localRoomsData.map((row) async {
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(row.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
return SnChatRoom(
id: row.id,
name: row.name,
description: row.description,
type: row.type,
isPublic: row.isPublic!,
isCommunity: row.isCommunity!,
picture: row.picture != null
? SnCloudFile.fromJson(row.picture!)
: null,
background: row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
realmId: row.realmId,
accountId: row.accountId,
realm: localRealmsData
.where((e) => e.id == row.realmId)
.map((e) => _buildRealmFromTableEntry(e))
.firstOrNull,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
members: members,
isPinned: row.isPinned ?? false,
);
}),
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get('/messager/chat');
final remoteRooms = resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(remoteRooms, override: true);
// Update state with fresh data
state = AsyncData(await _buildRoomsFromDb(db));
} catch (_) {}
}).ignore();
return localRooms;
}
} catch (_) {}
// Fallback to API
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat');
final rooms = resp.data
.map((e) => SnChatRoom.fromJson(e))
.cast<SnChatRoom>()
.toList();
await db.saveChatRooms(rooms, override: true);
return rooms;
}
SnRealm _buildRealmFromTableEntry(Realm localRealm) {
return SnRealm(
id: localRealm.id,
slug: localRealm.slug,
name: localRealm.name ?? localRealm.slug,
description: localRealm.description ?? '',
verifiedAs: localRealm.verifiedAs,
verifiedAt: localRealm.verifiedAt,
isCommunity: localRealm.isCommunity,
isPublic: localRealm.isPublic,
picture: localRealm.picture != null
? SnCloudFile.fromJson(localRealm.picture!)
: null,
background: localRealm.background != null
? SnCloudFile.fromJson(localRealm.background!)
: null,
accountId: localRealm.accountId ?? '',
createdAt: localRealm.createdAt,
updatedAt: localRealm.updatedAt,
deletedAt: localRealm.deletedAt,
);
}
Future<List<SnChatRoom>> _buildRoomsFromDb(AppDatabase db) async {
final localRoomsData = await db.select(db.chatRooms).get();
return Future.wait(
localRoomsData.map((row) async {
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(row.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
// Load realm if it exists
SnRealm? realm;
if (row.realmId != null) {
try {
final realmRow = await (db.select(
db.realms,
)..where((r) => r.id.equals(row.realmId!))).getSingleOrNull();
if (realmRow != null) {
realm = SnRealm(
id: realmRow.id,
slug: '', // Not stored in DB
name: realmRow.name ?? '',
description: realmRow.description ?? '',
verifiedAs: null, // Not stored in DB
verifiedAt: null, // Not stored in DB
isCommunity: false, // Not stored in DB
isPublic: true, // Not stored in DB
picture: realmRow.picture != null
? SnCloudFile.fromJson(realmRow.picture!)
: null,
background: realmRow.background != null
? SnCloudFile.fromJson(realmRow.background!)
: null,
accountId: realmRow.accountId ?? '',
createdAt: realmRow.createdAt,
updatedAt: realmRow.updatedAt,
deletedAt: realmRow.deletedAt,
);
}
} catch (_) {
// Realm not found, keep as null
}
}
return SnChatRoom(
id: row.id,
name: row.name,
description: row.description,
type: row.type,
isPublic: row.isPublic!,
isCommunity: row.isCommunity!,
picture: row.picture != null
? SnCloudFile.fromJson(row.picture!)
: null,
background: row.background != null
? SnCloudFile.fromJson(row.background!)
: null,
realmId: row.realmId,
accountId: row.accountId,
realm: realm,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
members: members,
isPinned: row.isPinned ?? false,
);
}),
);
}
}
@riverpod
class ChatRoomNotifier extends _$ChatRoomNotifier {
@override
Future<SnChatRoom?> build(String? identifier) async {
if (identifier == null) return null;
final db = ref.watch(databaseProvider);
try {
// Try to get from local database first
final localRoomData = await (db.select(
db.chatRooms,
)..where((r) => r.id.equals(identifier))).getSingleOrNull();
if (localRoomData != null) {
// Fetch members for this room
final membersRows = await (db.select(
db.chatMembers,
)..where((m) => m.chatRoomId.equals(localRoomData.id))).get();
final members = membersRows.map((mRow) {
final account = SnAccount.fromJson(mRow.account);
return SnChatMember(
id: mRow.id,
chatRoomId: mRow.chatRoomId,
accountId: mRow.accountId,
account: account,
nick: mRow.nick,
notify: mRow.notify,
joinedAt: mRow.joinedAt,
breakUntil: mRow.breakUntil,
timeoutUntil: mRow.timeoutUntil,
status: null,
createdAt: mRow.createdAt,
updatedAt: mRow.updatedAt,
deletedAt: mRow.deletedAt,
chatRoom: null,
);
}).toList();
final localRoom = SnChatRoom(
id: localRoomData.id,
name: localRoomData.name,
description: localRoomData.description,
type: localRoomData.type,
isPublic: localRoomData.isPublic!,
isCommunity: localRoomData.isCommunity!,
picture: localRoomData.picture != null
? SnCloudFile.fromJson(localRoomData.picture!)
: null,
background: localRoomData.background != null
? SnCloudFile.fromJson(localRoomData.background!)
: null,
realmId: localRoomData.realmId,
accountId: localRoomData.accountId,
realm: null,
createdAt: localRoomData.createdAt,
updatedAt: localRoomData.updatedAt,
deletedAt: localRoomData.deletedAt,
members: members,
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get('/messager/chat/$identifier');
final remoteRoom = SnChatRoom.fromJson(resp.data);
// Update state with fresh data directly without saving to DB
// DB will be updated by ChatRoomJoinedNotifier's full sync
state = AsyncData(remoteRoom);
} catch (_) {}
}).ignore();
return localRoom;
}
} catch (_) {}
// Fallback to API
try {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat/$identifier');
final room = SnChatRoom.fromJson(resp.data);
await db.saveChatRooms([room]);
return room;
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // Chat room not found
}
rethrow; // Rethrow other errors
}
}
}
@riverpod
class ChatRoomIdentityNotifier extends _$ChatRoomIdentityNotifier {
@override
Future<SnChatMember?> build(String? identifier) async {
if (identifier == null) return null;
final db = ref.watch(databaseProvider);
final userInfo = ref.watch(userInfoProvider);
try {
// Try to get from local database first
if (userInfo.value != null) {
final localMemberData =
await (db.select(db.chatMembers)
..where((m) => m.chatRoomId.equals(identifier))
..where((m) => m.accountId.equals(userInfo.value!.id)))
.getSingleOrNull();
if (localMemberData != null) {
final account = SnAccount.fromJson(localMemberData.account);
final localMember = SnChatMember(
id: localMemberData.id,
chatRoomId: localMemberData.chatRoomId,
accountId: localMemberData.accountId,
account: account,
nick: localMemberData.nick,
notify: localMemberData.notify,
joinedAt: localMemberData.joinedAt,
breakUntil: localMemberData.breakUntil,
timeoutUntil: localMemberData.timeoutUntil,
status: null,
createdAt: localMemberData.createdAt,
updatedAt: localMemberData.updatedAt,
deletedAt: localMemberData.deletedAt,
chatRoom: null,
);
// Background sync
Future(() async {
try {
final client = ref.read(apiClientProvider);
final resp = await client.get(
'/messager/chat/$identifier/members/me',
);
final remoteMember = SnChatMember.fromJson(resp.data);
await db.saveMember(remoteMember);
// Update state with fresh data
if (userInfo.value != null) {
state = AsyncData(
await _buildMemberFromDb(db, identifier, userInfo.value!.id),
);
}
} catch (_) {}
}).ignore();
return localMember;
}
}
} catch (_) {}
// Fallback to API
try {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat/$identifier/members/me');
final member = SnChatMember.fromJson(resp.data);
await db.saveMember(member);
return member;
} catch (err) {
if (err is DioException && err.response?.statusCode == 404) {
return null; // Chat member not found
}
rethrow; // Rethrow other errors
}
}
Future<SnChatMember?> _buildMemberFromDb(
AppDatabase db,
String identifier,
String accountId,
) async {
final localMemberData =
await (db.select(db.chatMembers)
..where((m) => m.chatRoomId.equals(identifier))
..where((m) => m.accountId.equals(accountId)))
.getSingleOrNull();
if (localMemberData == null) return null;
final account = SnAccount.fromJson(localMemberData.account);
return SnChatMember(
id: localMemberData.id,
chatRoomId: localMemberData.chatRoomId,
accountId: localMemberData.accountId,
account: account,
nick: localMemberData.nick,
notify: localMemberData.notify,
joinedAt: localMemberData.joinedAt,
breakUntil: localMemberData.breakUntil,
timeoutUntil: localMemberData.timeoutUntil,
status: null,
createdAt: localMemberData.createdAt,
updatedAt: localMemberData.updatedAt,
deletedAt: localMemberData.deletedAt,
chatRoom: null,
);
}
}
@riverpod
Future<List<SnChatMember>> chatroomInvites(Ref ref) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat/invites');
return resp.data
.map((e) => SnChatMember.fromJson(e))
.cast<SnChatMember>()
.toList();
}

279
lib/chat/chat_room.g.dart Normal file
View File

@@ -0,0 +1,279 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_room.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatRoomJoinedNotifier)
final chatRoomJoinedProvider = ChatRoomJoinedNotifierProvider._();
final class ChatRoomJoinedNotifierProvider
extends $AsyncNotifierProvider<ChatRoomJoinedNotifier, List<SnChatRoom>> {
ChatRoomJoinedNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatRoomJoinedProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatRoomJoinedNotifierHash();
@$internal
@override
ChatRoomJoinedNotifier create() => ChatRoomJoinedNotifier();
}
String _$chatRoomJoinedNotifierHash() =>
r'b3726e10298b99a8529c5e28a5c402b95016f096';
abstract class _$ChatRoomJoinedNotifier
extends $AsyncNotifier<List<SnChatRoom>> {
FutureOr<List<SnChatRoom>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref as $Ref<AsyncValue<List<SnChatRoom>>, List<SnChatRoom>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<SnChatRoom>>, List<SnChatRoom>>,
AsyncValue<List<SnChatRoom>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(ChatRoomNotifier)
final chatRoomProvider = ChatRoomNotifierFamily._();
final class ChatRoomNotifierProvider
extends $AsyncNotifierProvider<ChatRoomNotifier, SnChatRoom?> {
ChatRoomNotifierProvider._({
required ChatRoomNotifierFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'chatRoomProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatRoomNotifierHash();
@override
String toString() {
return r'chatRoomProvider'
''
'($argument)';
}
@$internal
@override
ChatRoomNotifier create() => ChatRoomNotifier();
@override
bool operator ==(Object other) {
return other is ChatRoomNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatRoomNotifierHash() => r'9f7a8bdd4af381c6b60e65e74363a0af3c1a650e';
final class ChatRoomNotifierFamily extends $Family
with
$ClassFamilyOverride<
ChatRoomNotifier,
AsyncValue<SnChatRoom?>,
SnChatRoom?,
FutureOr<SnChatRoom?>,
String?
> {
ChatRoomNotifierFamily._()
: super(
retry: null,
name: r'chatRoomProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatRoomNotifierProvider call(String? identifier) =>
ChatRoomNotifierProvider._(argument: identifier, from: this);
@override
String toString() => r'chatRoomProvider';
}
abstract class _$ChatRoomNotifier extends $AsyncNotifier<SnChatRoom?> {
late final _$args = ref.$arg as String?;
String? get identifier => _$args;
FutureOr<SnChatRoom?> build(String? identifier);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<SnChatRoom?>, SnChatRoom?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SnChatRoom?>, SnChatRoom?>,
AsyncValue<SnChatRoom?>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(ChatRoomIdentityNotifier)
final chatRoomIdentityProvider = ChatRoomIdentityNotifierFamily._();
final class ChatRoomIdentityNotifierProvider
extends $AsyncNotifierProvider<ChatRoomIdentityNotifier, SnChatMember?> {
ChatRoomIdentityNotifierProvider._({
required ChatRoomIdentityNotifierFamily super.from,
required String? super.argument,
}) : super(
retry: null,
name: r'chatRoomIdentityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatRoomIdentityNotifierHash();
@override
String toString() {
return r'chatRoomIdentityProvider'
''
'($argument)';
}
@$internal
@override
ChatRoomIdentityNotifier create() => ChatRoomIdentityNotifier();
@override
bool operator ==(Object other) {
return other is ChatRoomIdentityNotifierProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatRoomIdentityNotifierHash() =>
r'1ce75462a19cc037c97ee6084a30fee1f5335875';
final class ChatRoomIdentityNotifierFamily extends $Family
with
$ClassFamilyOverride<
ChatRoomIdentityNotifier,
AsyncValue<SnChatMember?>,
SnChatMember?,
FutureOr<SnChatMember?>,
String?
> {
ChatRoomIdentityNotifierFamily._()
: super(
retry: null,
name: r'chatRoomIdentityProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatRoomIdentityNotifierProvider call(String? identifier) =>
ChatRoomIdentityNotifierProvider._(argument: identifier, from: this);
@override
String toString() => r'chatRoomIdentityProvider';
}
abstract class _$ChatRoomIdentityNotifier
extends $AsyncNotifier<SnChatMember?> {
late final _$args = ref.$arg as String?;
String? get identifier => _$args;
FutureOr<SnChatMember?> build(String? identifier);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<SnChatMember?>, SnChatMember?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<SnChatMember?>, SnChatMember?>,
AsyncValue<SnChatMember?>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(chatroomInvites)
final chatroomInvitesProvider = ChatroomInvitesProvider._();
final class ChatroomInvitesProvider
extends
$FunctionalProvider<
AsyncValue<List<SnChatMember>>,
List<SnChatMember>,
FutureOr<List<SnChatMember>>
>
with
$FutureModifier<List<SnChatMember>>,
$FutureProvider<List<SnChatMember>> {
ChatroomInvitesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatroomInvitesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatroomInvitesHash();
@$internal
@override
$FutureProviderElement<List<SnChatMember>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnChatMember>> create(Ref ref) {
return chatroomInvites(ref);
}
}
String _$chatroomInvitesHash() => r'fc23231d5f111b1c3796ffae2b471384b951861a';

View File

@@ -0,0 +1,294 @@
import "dart:async";
import "dart:convert";
import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:island/chat/chat_widgets/call_button.dart";
import "package:island/chat/messages_notifier.dart";
import "package:just_audio/just_audio.dart";
import "package:island/core/config.dart";
import "package:island/chat/chat_models/chat.dart";
import "package:island/chat/chat_pod/chat_room.dart";
import "package:island/core/lifecycle.dart";
import "package:island/core/websocket.dart";
import "package:island/talker.dart";
import "package:riverpod_annotation/riverpod_annotation.dart";
part 'chat_subscribe.g.dart';
final currentSubscribedChatIdProvider =
NotifierProvider<CurrentSubscribedChatIdNotifier, String?>(
CurrentSubscribedChatIdNotifier.new,
);
class CurrentSubscribedChatIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) => state = value;
}
@riverpod
class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
late SnChatRoom _chatRoom;
late SnChatMember _chatIdentity;
late MessagesNotifier _messagesNotifier;
final List<SnChatMember> _typingStatuses = [];
Timer? _typingCleanupTimer;
Timer? _typingCooldownTimer;
Timer? _periodicSubscribeTimer;
StreamSubscription? _wsSubscription;
Function? _sendMessage;
void _cleanupResources() {
if (_wsSubscription != null) {
_wsSubscription!.cancel();
_wsSubscription = null;
}
if (_typingCleanupTimer != null) {
_typingCleanupTimer!.cancel();
_typingCleanupTimer = null;
}
if (_periodicSubscribeTimer != null) {
_periodicSubscribeTimer!.cancel();
_periodicSubscribeTimer = null;
}
}
@override
List<SnChatMember> build(String roomId) {
final ws = ref.watch(websocketProvider);
final chatRoomAsync = ref.watch(chatRoomProvider(roomId));
final chatIdentityAsync = ref.watch(chatRoomIdentityProvider(roomId));
_messagesNotifier = ref.watch(messagesProvider(roomId).notifier);
_cleanupResources();
if (chatRoomAsync.isLoading || chatIdentityAsync.isLoading) {
return [];
}
if (chatRoomAsync.value == null || chatIdentityAsync.value == null) {
return [];
}
_chatRoom = chatRoomAsync.value!;
_chatIdentity = chatIdentityAsync.value!;
// Subscribe to messages
final wsState = ref.read(websocketStateProvider.notifier);
_sendMessage = wsState.sendMessage;
talker.info('[MessageSubscriber] Subscribing room $roomId');
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
Future.microtask(
() => ref.read(currentSubscribedChatIdProvider.notifier).set(roomId),
);
// Send initial read receipt
sendReadReceipt();
// Set up WebSocket listener
_wsSubscription = ws.dataStream.listen(onMessage);
// Set up typing status cleanup timer
_typingCleanupTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (_typingStatuses.isNotEmpty) {
// Remove typing statuses older than 5 seconds
final now = DateTime.now();
_typingStatuses.removeWhere((member) {
final lastTyped =
member.lastTyped ??
DateTime.now().subtract(const Duration(milliseconds: 1350));
return now.difference(lastTyped).inSeconds > 5;
});
state = List.of(_typingStatuses);
}
});
// Set up periodic subscribe timer (every 5 minutes)
_periodicSubscribeTimer = Timer.periodic(const Duration(minutes: 5), (_) {
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
});
ref.listen(appLifecycleStateProvider, (previous, next) {
final lifecycleState = next.value;
if (lifecycleState == AppLifecycleState.paused ||
lifecycleState == AppLifecycleState.inactive) {
// Unsubscribe when app goes to background
talker.info('[MessageSubscriber] Unsubscribing room $roomId');
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
} else if (lifecycleState == AppLifecycleState.resumed) {
// Resubscribe when app comes back to foreground
talker.info('[MessageSubscriber] Subscribing room $roomId');
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.subscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
}
});
final subscribedNotifier = ref.watch(
currentSubscribedChatIdProvider.notifier,
);
ref.onCancel(() {
talker.info('[MessageSubscriber] Unsubscribing room $roomId');
subscribedNotifier.set(null);
try {
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.unsubscribe',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
} catch (e, stackTrace) {
talker.error(
'[MessageSubscriber] Error sending unsubscribe message for room $roomId: $e\n$stackTrace',
);
}
try {
_cleanupResources();
} catch (e, stackTrace) {
talker.error(
'[MessageSubscriber] Error during cleanup for room $roomId: $e\n$stackTrace',
);
}
try {
if (_typingCooldownTimer != null) {
_typingCooldownTimer!.cancel();
}
} catch (e, stackTrace) {
talker.error(
'[MessageSubscriber] Error cancelling typing cooldown timer for room $roomId: $e\n$stackTrace',
);
}
});
return _typingStatuses;
}
Future<void> onMessage(WebSocketPacket pkt) async {
if (!pkt.type.startsWith('messages')) return;
if (['messages.read'].contains(pkt.type)) return;
if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) {
if (pkt.data?['room_id'] != _chatRoom.id) return;
if (pkt.data?['sender_id'] == _chatIdentity.id) return;
final sender = SnChatMember.fromJson(
pkt.data?['sender'],
).copyWith(lastTyped: DateTime.now());
// Check if the sender is already in the typing list
final existingIndex = _typingStatuses.indexWhere(
(member) => member.id == sender.id,
);
if (existingIndex >= 0) {
// Update the existing entry with new timestamp
_typingStatuses[existingIndex] = sender;
} else {
// Add new typing status
_typingStatuses.add(sender);
}
if (ref.mounted) state = List.of(_typingStatuses);
return;
}
final message = SnChatMessage.fromJson(pkt.data!);
if (message.chatRoomId != _chatRoom.id) return;
switch (pkt.type) {
case 'messages.new':
case 'messages.update':
case 'messages.delete':
if (message.type.startsWith('call')) {
// Handle the ongoing call.
ref.invalidate(ongoingCallProvider(message.chatRoomId));
}
_messagesNotifier.receiveMessage(message);
// Send read receipt for new message
sendReadReceipt();
// Play sound for new messages when app is unfocused
if (pkt.type == 'messages.new' &&
message.senderId != _chatIdentity.id &&
ref.read(appLifecycleStateProvider).value !=
AppLifecycleState.resumed &&
ref.read(appSettingsProvider).soundEffects) {
final player = AudioPlayer();
await player.setVolume(0.75);
await player.setAudioSource(
AudioSource.asset('assets/audio/messages.mp3'),
);
await player.play();
player.dispose();
}
}
}
void sendReadReceipt() {
// Send websocket packet
if (_sendMessage == null) return;
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.read',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
}
void sendTypingStatus() {
// Don't send if we're already in a cooldown period
if (_typingCooldownTimer != null) return;
// Send typing status immediately
if (_sendMessage == null) return;
_sendMessage!(
jsonEncode(
WebSocketPacket(
type: 'messages.typing',
data: {'chat_room_id': roomId},
endpoint: 'messager',
),
),
);
_typingCooldownTimer = Timer(const Duration(milliseconds: 850), () {
_typingCooldownTimer = null;
});
}
}

View File

@@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_subscribe.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatSubscribeNotifier)
final chatSubscribeProvider = ChatSubscribeNotifierFamily._();
final class ChatSubscribeNotifierProvider
extends $NotifierProvider<ChatSubscribeNotifier, List<SnChatMember>> {
ChatSubscribeNotifierProvider._({
required ChatSubscribeNotifierFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'chatSubscribeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatSubscribeNotifierHash();
@override
String toString() {
return r'chatSubscribeProvider'
''
'($argument)';
}
@$internal
@override
ChatSubscribeNotifier create() => ChatSubscribeNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<SnChatMember> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<SnChatMember>>(value),
);
}
@override
bool operator ==(Object other) {
return other is ChatSubscribeNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatSubscribeNotifierHash() =>
r'f2f5059a975fc44a41850459d6b7d041ff9d41cb';
final class ChatSubscribeNotifierFamily extends $Family
with
$ClassFamilyOverride<
ChatSubscribeNotifier,
List<SnChatMember>,
List<SnChatMember>,
List<SnChatMember>,
String
> {
ChatSubscribeNotifierFamily._()
: super(
retry: null,
name: r'chatSubscribeProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatSubscribeNotifierProvider call(String roomId) =>
ChatSubscribeNotifierProvider._(argument: roomId, from: this);
@override
String toString() => r'chatSubscribeProvider';
}
abstract class _$ChatSubscribeNotifier extends $Notifier<List<SnChatMember>> {
late final _$args = ref.$arg as String;
String get roomId => _$args;
List<SnChatMember> build(String roomId);
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<List<SnChatMember>, List<SnChatMember>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<List<SnChatMember>, List<SnChatMember>>,
List<SnChatMember>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}

163
lib/chat/chat_summary.dart Normal file
View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/core/network.dart';
import 'package:island/core/websocket.dart';
import 'package:island/chat/chat_pod/chat_subscribe.dart';
part 'chat_summary.g.dart';
@riverpod
class ChatUnreadCountNotifier extends _$ChatUnreadCountNotifier {
StreamSubscription<WebSocketPacket>? _subscription;
@override
Future<int> build() async {
// Subscribe to websocket events when this provider is built
_subscribeToWebSocket();
// Dispose the subscription when this provider is disposed
ref.onDispose(() {
_subscription?.cancel();
});
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/messager/chat/unread');
return (response.data as num).toInt();
} catch (_) {
return 0;
}
}
void _subscribeToWebSocket() {
final webSocketService = ref.read(websocketProvider);
_subscription = webSocketService.dataStream.listen((packet) {
if (packet.type == 'messages.new' && packet.data != null) {
final message = SnChatMessage.fromJson(packet.data!);
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
// Only increment if the message is not from the currently subscribed chat
if (message.chatRoomId != currentSubscribed) {
_incrementCounter();
}
}
});
}
Future<void> _incrementCounter() async {
final current = await future;
state = AsyncData(current + 1);
}
Future<void> decrement(int count) async {
final current = await future;
state = AsyncData(math.max(current - count, 0));
}
void clear() async {
state = AsyncData(0);
}
}
@Riverpod(keepAlive: true)
class ChatSummary extends _$ChatSummary {
@override
Future<Map<String, SnChatSummary>> build() async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/messager/chat/summary');
final Map<String, dynamic> data = resp.data;
final summaries = data.map(
(key, value) => MapEntry(key, SnChatSummary.fromJson(value)),
);
final ws = ref.watch(websocketProvider);
final subscription = ws.dataStream.listen((WebSocketPacket pkt) {
if (!pkt.type.startsWith('messages')) return;
if (pkt.type == 'messages.new') {
final message = SnChatMessage.fromJson(pkt.data!);
updateLastMessage(message.chatRoomId, message);
} else if (pkt.type == 'messages.update') {
final message = SnChatMessage.fromJson(pkt.data!);
updateMessageContent(message.chatRoomId, message);
}
});
ref.onDispose(() {
subscription.cancel();
});
return summaries;
}
Future<void> clearUnreadCount(String chatId) async {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null) {
// Decrement global unread count
final unreadToDecrement = summary.unreadCount;
if (unreadToDecrement > 0) {
ref
.read(chatUnreadCountProvider.notifier)
.decrement(unreadToDecrement);
}
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) {
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
final increment = (chatId != currentSubscribed) ? 1 : 0;
state = AsyncData({
...summaries,
chatId: SnChatSummary(
unreadCount: summary.unreadCount + increment,
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,
),
});
}
});
}
void updateMessageContent(String chatId, SnChatMessage message) {
state.whenData((summaries) {
final summary = summaries[chatId];
if (summary != null && summary.lastMessage?.id == message.id) {
state = AsyncData({
...summaries,
chatId: SnChatSummary(
unreadCount: summary.unreadCount,
lastMessage: message,
),
});
}
});
}
}

View File

@@ -0,0 +1,108 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_summary.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatUnreadCountNotifier)
final chatUnreadCountProvider = ChatUnreadCountNotifierProvider._();
final class ChatUnreadCountNotifierProvider
extends $AsyncNotifierProvider<ChatUnreadCountNotifier, int> {
ChatUnreadCountNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatUnreadCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatUnreadCountNotifierHash();
@$internal
@override
ChatUnreadCountNotifier create() => ChatUnreadCountNotifier();
}
String _$chatUnreadCountNotifierHash() =>
r'169b28f8759ebd9de75f7de17f60d493737ee7a8';
abstract class _$ChatUnreadCountNotifier extends $AsyncNotifier<int> {
FutureOr<int> build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<AsyncValue<int>, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<int>, int>,
AsyncValue<int>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@ProviderFor(ChatSummary)
final chatSummaryProvider = ChatSummaryProvider._();
final class ChatSummaryProvider
extends $AsyncNotifierProvider<ChatSummary, Map<String, SnChatSummary>> {
ChatSummaryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatSummaryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatSummaryHash();
@$internal
@override
ChatSummary create() => ChatSummary();
}
String _$chatSummaryHash() => r'82f516d4ce8b67dadb815523df57a3c30a33ef91';
abstract class _$ChatSummary
extends $AsyncNotifier<Map<String, SnChatSummary>> {
FutureOr<Map<String, SnChatSummary>> build();
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<
AsyncValue<Map<String, SnChatSummary>>,
Map<String, SnChatSummary>
>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<Map<String, SnChatSummary>>,
Map<String, SnChatSummary>
>,
AsyncValue<Map<String, SnChatSummary>>,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/call.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'call_button.g.dart';
@riverpod
Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
if (roomId.isEmpty) return null;
try {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/messager/chat/realtime/$roomId');
return SnRealtimeCall.fromJson(resp.data);
} catch (e) {
if (e is DioException && e.response?.statusCode == 404) {
return null;
}
showErrorAlert(e);
return null;
}
}
class AudioCallButton extends HookConsumerWidget {
final SnChatRoom room;
const AudioCallButton({super.key, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
final callState = ref.watch(callProvider);
final callNotifier = ref.read(callProvider.notifier);
final isLoading = useState(false);
final apiClient = ref.watch(apiClientProvider);
Future<void> handleJoin() async {
isLoading.value = true;
try {
await apiClient.post('/messager/chat/realtime/${room.id}');
ref.invalidate(ongoingCallProvider(room.id));
// Just join the room, the overlay will handle the UI
await callNotifier.joinRoom(room);
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}
Future<void> handleEnd() async {
isLoading.value = true;
try {
await apiClient.delete('/messager/chat/realtime/${room.id}');
callNotifier.dispose(); // Clean up call resources
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}
if (isLoading.value) {
return IconButton(
icon: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).appBarTheme.foregroundColor!,
padding: EdgeInsets.all(4),
),
),
onPressed: null,
);
}
if (callState.isConnected) {
// Show end call button if in call
return IconButton(
icon: const Icon(Icons.call_end),
tooltip: 'End Call',
onPressed: handleEnd,
);
}
if (ongoingCall.value != null) {
// There is an ongoing call, offer to join it directly
return IconButton(
icon: const Icon(Icons.call),
tooltip: 'Join Ongoing Call',
onPressed: () async {
isLoading.value = true;
try {
await callNotifier.joinRoom(room);
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
},
);
}
// Show join/start call button
return IconButton(
icon: const Icon(Icons.call),
tooltip: 'Start Call',
onPressed: handleJoin,
);
}
}

View File

@@ -0,0 +1,85 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'call_button.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ongoingCall)
final ongoingCallProvider = OngoingCallFamily._();
final class OngoingCallProvider
extends
$FunctionalProvider<
AsyncValue<SnRealtimeCall?>,
SnRealtimeCall?,
FutureOr<SnRealtimeCall?>
>
with $FutureModifier<SnRealtimeCall?>, $FutureProvider<SnRealtimeCall?> {
OngoingCallProvider._({
required OngoingCallFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'ongoingCallProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$ongoingCallHash();
@override
String toString() {
return r'ongoingCallProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnRealtimeCall?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnRealtimeCall?> create(Ref ref) {
final argument = this.argument as String;
return ongoingCall(ref, argument);
}
@override
bool operator ==(Object other) {
return other is OngoingCallProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$ongoingCallHash() => r'3d1efaaca2981ebf698e9241453dbf2b2f13bfe3';
final class OngoingCallFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnRealtimeCall?>, String> {
OngoingCallFamily._()
: super(
retry: null,
name: r'ongoingCallProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
OngoingCallProvider call(String roomId) =>
OngoingCallProvider._(argument: roomId, from: this);
@override
String toString() => r'ongoingCallProvider';
}

View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_pod/call.dart';
import 'package:island/chat/chat_widgets/call_participant_tile.dart';
import 'package:livekit_client/livekit_client.dart';
class CallStageView extends HookConsumerWidget {
final List<CallParticipantLive> participants;
final double? outerMaxHeight;
const CallStageView({
super.key,
required this.participants,
this.outerMaxHeight,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final focusedIndex = useState<int>(0);
final focusedParticipant = participants[focusedIndex.value];
final otherParticipants = participants
.where((p) => p != focusedParticipant)
.toList();
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Focused participant (takes most space)
LayoutBuilder(
builder: (context, constraints) {
// Calculate dynamic width based on available space
final maxWidth = constraints.maxWidth * 0.8;
final maxHeight = (outerMaxHeight ?? constraints.maxHeight) * 0.6;
return Container(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: CallParticipantTile(
live: focusedParticipant,
allTiles: true,
),
),
);
},
),
// Horizontal list of other participants
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final participant in otherParticipants)
Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
width: 180,
child: GestureDetector(
onTapDown: (_) {
final newIndex = participants.indexOf(participant);
focusedIndex.value = newIndex;
},
child: CallParticipantTile(
live: participant,
radius: 32,
allTiles: true,
),
),
),
),
],
),
),
],
);
}
}
class CallContent extends HookConsumerWidget {
final double? outerMaxHeight;
const CallContent({super.key, this.outerMaxHeight});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callProvider);
final callNotifier = ref.watch(callProvider.notifier);
if (!callState.isConnected) {
return const Center(child: CircularProgressIndicator());
}
if (callNotifier.participants.isEmpty) {
return const Center(child: Text('No participants in call'));
}
final participants = callNotifier.participants;
final allAudioOnly = participants.every(
(p) =>
!(p.hasVideo &&
p.remoteParticipant.trackPublications.values.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO &&
!pub.muted &&
!pub.isDisposed,
)),
);
if (allAudioOnly) {
// Audio-only: show avatars in a compact row with animated containers
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final live in participants)
Padding(
padding: const EdgeInsets.all(8),
child: SpeakingRippleAvatar(live: live, size: 72),
),
],
),
),
);
}
if (callState.viewMode == ViewMode.stage) {
// Stage: allow user to select a participant to focus, show others below
return CallStageView(
participants: participants,
outerMaxHeight: outerMaxHeight,
);
} else {
// Grid: show all participants in a responsive grid
return LayoutBuilder(
builder: (context, constraints) {
// Calculate width for responsive 2-column layout
final itemWidth = (constraints.maxWidth / 2) - 16;
return SingleChildScrollView(
child: Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final participant in participants)
SizedBox(
width: itemWidth,
child: CallParticipantTile(
live: participant,
allTiles: true,
),
),
],
),
);
},
);
}
}
}

View File

@@ -0,0 +1,605 @@
import 'package:animations/animations.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/call.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/chat/chat_widgets/call_button.dart';
import 'package:island/chat/chat_widgets/call_content.dart';
import 'package:island/chat/chat_widgets/call_participant_tile.dart';
import 'package:island/chat/chat_widgets/call_screen.dart';
import 'package:island/core/network.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:collection/collection.dart';
class CallControlsBar extends HookConsumerWidget {
final bool isCompact;
final bool popOnLeaves;
const CallControlsBar({
super.key,
this.isCompact = false,
this.popOnLeaves = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callProvider);
final callNotifier = ref.read(callProvider.notifier);
return Container(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 12 : 20,
vertical: isCompact ? 8 : 16,
),
child: Wrap(
alignment: WrapAlignment.center,
runSpacing: isCompact ? 12 : 16,
spacing: isCompact ? 12 : 16,
children: [
_buildCircularButtonWithDropdown(
context: context,
ref: ref,
icon: callState.isCameraEnabled
? Symbols.videocam
: Symbols.videocam_off,
onPressed: () => callNotifier.toggleCamera(),
backgroundColor: const Color(0xFF424242),
hasDropdown: true,
deviceType: 'videoinput',
),
_buildCircularButton(
icon: callState.isScreenSharing
? Symbols.stop_screen_share
: Symbols.screen_share,
onPressed: () => callNotifier.toggleScreenShare(context),
backgroundColor: const Color(0xFF424242),
),
_buildCircularButtonWithDropdown(
context: context,
ref: ref,
icon: callState.isMicrophoneEnabled ? Symbols.mic : Symbols.mic_off,
onPressed: () => callNotifier.toggleMicrophone(),
backgroundColor: const Color(0xFF424242),
hasDropdown: true,
deviceType: 'audioinput',
),
_buildCircularButton(
icon: callState.isSpeakerphone
? Symbols.mobile_speaker
: Symbols.ear_sound,
onPressed: () => callNotifier.toggleSpeakerphone(),
backgroundColor: const Color(0xFF424242),
),
_buildCircularButton(
icon: callState.viewMode == ViewMode.grid
? Symbols.grid_view
: Symbols.view_list,
onPressed: () => callNotifier.toggleViewMode(),
backgroundColor: const Color(0xFF424242),
),
_buildCircularButton(
icon: Icons.call_end,
onPressed: () => showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (innerContext) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Gap(24),
ListTile(
leading: const Icon(Symbols.logout, fill: 1),
title: Text('callLeave').tr(),
onTap: () {
callNotifier.disconnect();
if (popOnLeaves) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
Navigator.of(innerContext).pop();
}
},
),
ListTile(
leading: const Icon(Symbols.call_end, fill: 1),
iconColor: Colors.red,
title: Text('callEnd').tr(),
onTap: () async {
callNotifier.disconnect();
final apiClient = ref.watch(apiClientProvider);
try {
showLoadingModal(context);
await apiClient.delete(
'/messager/chat/realtime/${callNotifier.roomId}',
);
callNotifier.dispose();
if (context.mounted && popOnLeaves) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
Navigator.of(innerContext).pop();
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
),
backgroundColor: const Color(0xFFE53E3E),
iconColor: Colors.white,
),
],
),
);
}
Widget _buildCircularButton({
required IconData icon,
required VoidCallback onPressed,
required Color backgroundColor,
Color? iconColor,
}) {
final size = isCompact ? 40.0 : 56.0;
final iconSize = isCompact ? 20.0 : 24.0;
return Container(
width: size,
height: size,
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: IconButton(
icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
onPressed: onPressed,
),
);
}
Widget _buildCircularButtonWithDropdown({
required BuildContext context,
required WidgetRef ref,
required IconData icon,
required VoidCallback onPressed,
required Color backgroundColor,
required bool hasDropdown,
Color? iconColor,
String? deviceType, // 'videoinput' or 'audioinput'
}) {
final size = isCompact ? 40.0 : 56.0;
final iconSize = isCompact ? 20.0 : 24.0;
return Stack(
clipBehavior: Clip.none,
children: [
Container(
width: size,
height: size,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
onPressed: onPressed,
),
),
if (hasDropdown && deviceType != null)
Positioned(
bottom: 0,
right: isCompact ? 0 : -4,
child: Material(
color: Colors
.transparent, // Make Material transparent to show underlying color
child: InkWell(
onTap: () =>
_showDeviceSelectionDialog(context, ref, deviceType),
borderRadius: BorderRadius.circular((isCompact ? 16 : 24) / 2),
child: Container(
width: isCompact ? 16 : 24,
height: isCompact ? 16 : 24,
decoration: BoxDecoration(
color: backgroundColor.withOpacity(0.8),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 0.5,
),
),
child: Icon(
Icons.arrow_drop_down,
color: Colors.white,
size: isCompact ? 12 : 20,
),
),
),
),
),
],
);
}
Future<void> _showDeviceSelectionDialog(
BuildContext context,
WidgetRef ref,
String deviceType,
) async {
try {
final devices = await Hardware.instance.enumerateDevices(
type: deviceType,
);
if (!context.mounted) return;
showModalBottomSheet(
context: context,
builder: (BuildContext dialogContext) {
return SheetScaffold(
titleText: deviceType == 'videoinput'
? 'selectCamera'.tr()
: 'selectMicrophone'.tr(),
child: ListView.builder(
itemCount: devices.length,
itemBuilder: (context, index) {
final device = devices[index];
return ListTile(
title: Text(
device.label.isNotEmpty
? device.label
: '${'device'.tr()} ${index + 1}',
),
onTap: () {
Navigator.of(dialogContext).pop();
_switchDevice(context, ref, device, deviceType);
},
);
},
),
);
},
);
} catch (e) {
showErrorAlert(e);
}
}
Future<void> _switchDevice(
BuildContext context,
WidgetRef ref,
MediaDevice device,
String deviceType,
) async {
try {
final callNotifier = ref.read(callProvider.notifier);
if (deviceType == 'videoinput') {
// Switch camera device
final localParticipant = callNotifier.room?.localParticipant;
final videoTrack =
localParticipant?.videoTrackPublications.firstOrNull?.track;
if (videoTrack is LocalVideoTrack) {
await videoTrack.switchCamera(device.deviceId);
}
} else if (deviceType == 'audioinput') {
// Switch microphone device
final localParticipant = callNotifier.room?.localParticipant;
final audioTrack =
localParticipant?.audioTrackPublications.firstOrNull?.track;
if (audioTrack is LocalAudioTrack) {
// For audio devices, we need to restart the track with new device
await audioTrack.restartTrack(
AudioCaptureOptions(deviceId: device.deviceId),
);
}
}
if (context.mounted) {
showSnackBar(
'switchedTo'.tr(
args: [device.label.isNotEmpty ? device.label : 'device'],
),
);
}
} catch (err) {
showErrorAlert(err);
}
}
}
class CallOverlayBar extends HookConsumerWidget {
final SnChatRoom room;
const CallOverlayBar({super.key, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Use selective watching to reduce rebuilds
final isConnected = ref.watch(
callProvider.select((state) => state.isConnected),
);
final duration = ref.watch(callProvider.select((state) => state.duration));
final isMicrophoneEnabled = ref.watch(
callProvider.select((state) => state.isMicrophoneEnabled),
);
final callNotifier = ref.read(callProvider.notifier);
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
// Memoize expensive computations
final lastSpeaker = useMemoized(() {
final participants = callNotifier.participants;
if (participants.isEmpty) return null;
final speakers = participants.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
);
if (speakers.isEmpty) return participants.first;
return speakers.fold<CallParticipantLive?>(null, (previous, current) {
if (previous == null) return current;
return current.remoteParticipant.lastSpokeAt!.compareTo(
previous.remoteParticipant.lastSpokeAt!,
) >
0
? current
: previous;
});
}, [callNotifier.participants]);
final userInfo = ref.watch(userInfoProvider).value!;
// Memoize chat room name
final chatRoomName = useMemoized(() {
final room = callNotifier.chatRoom;
if (room == null) return 'unnamed'.tr();
return room.name ??
(room.members ?? [])
.where((element) => element.id != userInfo.id)
.map((element) => element.account.nick)
.first;
}, [callNotifier.chatRoom, userInfo]);
// State for overlay mode: compact or preview
// Default to true (preview mode) so user sees video immediately after joining
final isExpanded = useState(true);
Widget child;
if (isConnected) {
child = _buildActiveCallOverlay(
context,
ref,
duration,
isMicrophoneEnabled,
callNotifier,
lastSpeaker,
chatRoomName,
isExpanded,
);
} else if (ongoingCall.value != null) {
child = _buildJoinPrompt(context, ref);
} else {
child = const SizedBox.shrink(key: ValueKey('empty'));
}
return AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: <Widget>[...previousChildren, ?currentChild],
);
},
child: child,
),
);
}
Widget _buildJoinPrompt(BuildContext context, WidgetRef ref) {
final isLoading = useState(false);
return Card(
key: const ValueKey('join_prompt'),
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.videocam,
color: Theme.of(context).colorScheme.onPrimary,
size: 20,
),
),
const Gap(12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Call in progress').bold(),
Text('Tap to join', style: Theme.of(context).textTheme.bodySmall),
],
),
const Spacer(),
if (isLoading.value)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
).padding(right: 8)
else
FilledButton.icon(
onPressed: () async {
isLoading.value = true;
try {
// Just join the room, don't navigate
await ref.read(callProvider.notifier).joinRoom(room);
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
},
icon: const Icon(Icons.call, size: 18),
label: const Text('Join'),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
).padding(all: 12),
);
}
Widget _buildActiveCallOverlay(
BuildContext context,
WidgetRef ref,
Duration duration,
bool isMicrophoneEnabled,
CallNotifier callNotifier,
CallParticipantLive? lastSpeaker,
String chatRoomName,
ValueNotifier<bool> isExpanded,
) {
if (lastSpeaker == null) {
return const SizedBox.shrink(key: ValueKey('active_waiting'));
}
// Preview Mode (Expanded)
if (isExpanded.value) {
return Card(
key: const ValueKey('active_expanded'),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
const Gap(4),
Text(chatRoomName),
const Gap(4),
Text(formatDuration(duration)).bold(),
const Spacer(),
OpenContainer(
closedElevation: 0,
closedColor: Colors.transparent,
openColor: Theme.of(context).scaffoldBackgroundColor,
middleColor: Theme.of(context).scaffoldBackgroundColor,
openBuilder: (context, action) => CallScreen(room: room),
closedBuilder: (context, openContainer) => IconButton(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Icons.fullscreen),
onPressed: openContainer,
tooltip: 'Full Screen',
),
),
IconButton(
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -4,
),
icon: const Icon(Icons.expand_less),
onPressed: () => isExpanded.value = false,
tooltip: 'Collapse',
),
],
).padding(horizontal: 12, vertical: 8),
// Video Preview
Container(
height: 320,
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const CallContent(outerMaxHeight: 320),
),
const CallControlsBar(
isCompact: true,
).padding(vertical: 8, horizontal: 16),
],
),
);
}
// Compact Mode
return GestureDetector(
key: const ValueKey('active_collapsed'),
onTap: () => isExpanded.value = true,
child: Card(
margin: EdgeInsets.zero,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
children: [
SizedBox(
width: 40,
height: 40,
child: SpeakingRippleAvatar(
live: lastSpeaker,
size: 36,
).center(),
),
const Gap(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('@${lastSpeaker.participant.identity}').bold(),
Row(
spacing: 4,
children: [
Text(
chatRoomName,
style: Theme.of(context).textTheme.bodySmall,
),
Text(
formatDuration(duration),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
],
),
),
IconButton(
icon: Icon(
isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
size: 20,
),
onPressed: () {
callNotifier.toggleMicrophone();
},
),
IconButton(
icon: const Icon(Icons.expand_more),
onPressed: () => isExpanded.value = true,
tooltip: 'Expand',
),
],
).padding(all: 12),
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_popup_card/flutter_popup_card.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_widgets/account/account_nameplate.dart';
import 'package:island/chat/chat_pod/call.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:styled_widget/styled_widget.dart';
class CallParticipantCard extends HookConsumerWidget {
final CallParticipantLive live;
const CallParticipantCard({super.key, required this.live});
@override
Widget build(BuildContext context, WidgetRef ref) {
final width = math
.min(MediaQuery.of(context).size.width - 80, 360)
.toDouble();
final callNotifier = ref.watch(callProvider.notifier);
final volumeSliderValue = useState(callNotifier.getParticipantVolume(live));
return PopupCard(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
child: SizedBox(
width: width,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
spacing: 4,
children: [
Row(
children: [
const Icon(Symbols.sound_detection_loud_sound, size: 16),
const Gap(8),
Expanded(
child: Slider(
max: 2,
value: volumeSliderValue.value,
onChanged: (value) {
volumeSliderValue.value = value;
},
onChangeEnd: (value) {
callNotifier.setParticipantVolume(live, value);
},
year2023: true,
padding: EdgeInsets.zero,
),
),
const Gap(16),
SizedBox(
width: 40,
child: Text(
'${(volumeSliderValue.value * 100).toStringAsFixed(0)}%',
),
),
],
),
Row(
children: [
const Icon(Symbols.wifi, size: 16),
const Gap(8),
Text(switch (live.remoteParticipant.connectionQuality) {
ConnectionQuality.excellent => 'Excellent',
ConnectionQuality.good => 'Good',
ConnectionQuality.poor => 'Bad',
ConnectionQuality.lost => 'Lost',
_ => 'Connecting',
}),
],
),
],
).padding(horizontal: 20, top: 16),
AccountNameplate(
name: live.participant.identity,
isOutlined: false,
),
],
),
),
);
}
}
class CallParticipantRegion extends StatelessWidget {
final CallParticipantLive participant;
final Widget child;
const CallParticipantRegion({
super.key,
required this.participant,
required this.child,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: child,
onTapDown: (details) {
showCallParticipantCard(
context,
participant,
offset: details.localPosition,
);
},
);
}
}
Future<void> showCallParticipantCard(
BuildContext context,
CallParticipantLive participant, {
Offset? offset,
}) async {
await showPopupCard<void>(
offset: offset ?? Offset.zero,
context: context,
builder: (context) => CallParticipantCard(live: participant),
alignment: Alignment.center,
dimBackground: true,
);
}

View File

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

View File

@@ -0,0 +1,131 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/call.dart';
import 'package:island/chat/chat_widgets/call_button.dart';
import 'package:island/chat/chat_widgets/call_content.dart';
import 'package:island/chat/chat_widgets/call_overlay.dart';
import 'package:island/chat/chat_widgets/call_participant_tile.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/talker.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
class CallScreen extends HookConsumerWidget {
final SnChatRoom room;
const CallScreen({super.key, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
final callState = ref.watch(callProvider);
final callNotifier = ref.watch(callProvider.notifier);
useEffect(() {
talker.info('[Call] Joining the call...');
callNotifier.joinRoom(room).catchError((_) {
showConfirmAlert(
'Seems there already has a call connected, do you want override it?',
'Call already connected',
).then((value) {
if (value != true) return;
talker.info('[Call] Joining the call... with overrides');
callNotifier.disconnect();
callNotifier.dispose();
callNotifier.joinRoom(room);
});
});
return null;
}, []);
final allAudioOnly = callNotifier.participants.every(
(p) =>
!(p.hasVideo &&
p.remoteParticipant.trackPublications.values.any(
(pub) =>
pub.track != null &&
pub.kind == TrackType.VIDEO &&
!pub.muted &&
!pub.isDisposed,
)),
);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: PageBackButton(),
title: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
ongoingCall.value?.room.name ?? 'call'.tr(),
style: const TextStyle(fontSize: 16),
),
Text(
callState.isConnected
? formatDuration(callState.duration)
: (switch (callNotifier.room?.connectionState) {
ConnectionState.connected => 'connected',
ConnectionState.connecting => 'connecting',
ConnectionState.reconnecting => 'reconnecting',
_ => 'disconnected',
}).tr(),
style: const TextStyle(fontSize: 14),
),
],
),
actions: [
if (!allAudioOnly)
SingleChildScrollView(
child: Row(
spacing: 4,
children: [
for (final live in callNotifier.participants)
SpeakingRippleAvatar(live: live, size: 30),
const Gap(8),
],
),
),
],
),
body: callState.error != null
? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
children: [
const Icon(Symbols.error_outline, size: 48),
const Gap(4),
Text(
callState.error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
),
const Gap(8),
TextButton(
onPressed: () {
callNotifier.disconnect();
callNotifier.dispose();
callNotifier.joinRoom(room);
},
child: Text('retry').tr(),
),
],
),
),
)
: Column(
children: [
const SizedBox(width: double.infinity),
Expanded(child: CallContent()),
CallControlsBar(popOnLeaves: true),
Gap(MediaQuery.of(context).padding.bottom + 16),
],
),
);
}
}

View File

@@ -0,0 +1,756 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_widgets/account/account_pfc.dart';
import 'package:island/accounts/accounts_widgets/account/account_picker.dart';
import 'package:island/accounts/accounts_widgets/account/status.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/chat_room.dart';
import 'package:island/chat/chat_widgets/chat_room_form.dart';
import 'package:island/chat/chat_widgets/chat_search_screen.dart';
import 'package:island/core/database.dart';
import 'package:island/core/network.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/pagination/pagination.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/shared/widgets/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'chat_detail_screen.freezed.dart';
part 'chat_detail_screen.g.dart';
@riverpod
Future<int> totalMessagesCount(Ref ref, String roomId) async {
final database = ref.watch(databaseProvider);
return database.getTotalMessagesForRoom(roomId);
}
class ChatDetailScreen extends HookConsumerWidget {
final String id;
const ChatDetailScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final roomState = ref.watch(chatRoomProvider(id));
final roomIdentity = ref.watch(chatRoomIdentityProvider(id));
final totalMessages = ref.watch(totalMessagesCountProvider(id));
// Local state for pinned status to provide immediate UI feedback
final isPinned = useState<bool?>(null);
// Initialize pinned state from database
useEffect(() {
final db = ref.read(databaseProvider);
(db.select(
db.chatRooms,
)..where((r) => r.id.equals(id))).getSingleOrNull().then((room) {
isPinned.value = room?.isPinned ?? false;
});
return null;
}, [id]);
const kNotifyLevelText = [
'chatNotifyLevelAll',
'chatNotifyLevelMention',
'chatNotifyLevelNone',
];
void setNotifyLevel(int level) async {
try {
final client = ref.watch(apiClientProvider);
await client.patch(
'/messager/chat/$id/members/me/notify',
data: {'notify_level': level},
);
ref.invalidate(chatRoomIdentityProvider(id));
if (context.mounted) {
showSnackBar(
'chatNotifyLevelUpdated'.tr(args: [kNotifyLevelText[level].tr()]),
);
}
} catch (err) {
showErrorAlert(err);
}
}
void setChatBreak(DateTime until) async {
try {
final client = ref.watch(apiClientProvider);
await client.patch(
'/messager/chat/$id/members/me/notify',
data: {'break_until': until.toUtc().toIso8601String()},
);
ref.invalidate(chatRoomIdentityProvider(id));
} catch (err) {
showErrorAlert(err);
}
}
void showNotifyLevelBottomSheet(SnChatMember identity) {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => SheetScaffold(
height: 320,
titleText: 'chatNotifyLevel'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('chatNotifyLevelAll').tr(),
subtitle: const Text('chatNotifyLevelDescription').tr(),
leading: const Icon(Icons.notifications_active),
selected: identity.notify == 0,
onTap: () {
setNotifyLevel(0);
Navigator.pop(context);
},
),
ListTile(
title: const Text('chatNotifyLevelMention').tr(),
subtitle: const Text('chatNotifyLevelDescription').tr(),
leading: const Icon(Icons.alternate_email),
selected: identity.notify == 1,
onTap: () {
setNotifyLevel(1);
Navigator.pop(context);
},
),
ListTile(
title: const Text('chatNotifyLevelNone').tr(),
subtitle: const Text('chatNotifyLevelDescription').tr(),
leading: const Icon(Icons.notifications_off),
selected: identity.notify == 2,
onTap: () {
setNotifyLevel(2);
Navigator.pop(context);
},
),
],
),
),
);
}
void showChatBreakDialog() {
final now = DateTime.now();
final durationController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('chatBreak').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('chatBreakDescription').tr(),
const Gap(16),
ListTile(
title: const Text('chatBreakClearButton').tr(),
subtitle: const Text('chatBreakClear').tr(),
leading: const Icon(Icons.notifications_active),
onTap: () {
setChatBreak(now);
Navigator.pop(context);
if (context.mounted) {
showSnackBar('chatBreakCleared'.tr());
}
},
),
ListTile(
title: const Text('chatBreak5m').tr(),
subtitle: const Text(
'chatBreakHour',
).tr(args: ['chatBreak5m'.tr()]),
leading: const Icon(Symbols.circle),
onTap: () {
setChatBreak(now.add(const Duration(minutes: 5)));
Navigator.pop(context);
if (context.mounted) {
showSnackBar('chatBreakSet'.tr(args: ['5m']));
}
},
),
ListTile(
title: const Text('chatBreak10m').tr(),
subtitle: const Text(
'chatBreakHour',
).tr(args: ['chatBreak10m'.tr()]),
leading: const Icon(Symbols.circle),
onTap: () {
setChatBreak(now.add(const Duration(minutes: 10)));
Navigator.pop(context);
if (context.mounted) {
showSnackBar('chatBreakSet'.tr(args: ['10m']));
}
},
),
ListTile(
title: const Text('chatBreak15m').tr(),
subtitle: const Text(
'chatBreakHour',
).tr(args: ['chatBreak15m'.tr()]),
leading: const Icon(Symbols.timer_3),
onTap: () {
setChatBreak(now.add(const Duration(minutes: 15)));
Navigator.pop(context);
if (context.mounted) {
showSnackBar('chatBreakSet'.tr(args: ['15m']));
}
},
),
ListTile(
title: const Text('chatBreak30m').tr(),
subtitle: const Text(
'chatBreakHour',
).tr(args: ['chatBreak30m'.tr()]),
leading: const Icon(Symbols.timer),
onTap: () {
setChatBreak(now.add(const Duration(minutes: 30)));
Navigator.pop(context);
if (context.mounted) {
showSnackBar('chatBreakSet'.tr(args: ['30m']));
}
},
),
const Gap(8),
TextField(
controller: durationController,
decoration: InputDecoration(
labelText: 'chatBreakCustomMinutes'.tr(),
hintText: 'chatBreakEnterMinutes'.tr(),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.check),
onPressed: () {
final minutes = int.tryParse(durationController.text);
if (minutes != null && minutes > 0) {
setChatBreak(now.add(Duration(minutes: minutes)));
Navigator.pop(context);
if (context.mounted) {
showSnackBar(
'chatBreakSet'.tr(args: ['${minutes}m']),
);
}
}
},
),
),
keyboardType: TextInputType.number,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('cancel').tr(),
),
],
),
);
}
const iconShadow = Shadow(
color: Colors.black54,
blurRadius: 5.0,
offset: Offset(1.0, 1.0),
);
return AppScaffold(
body: roomState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('errorGeneric'.tr(args: [error.toString()]))),
data: (currentRoom) => CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 180,
pinned: true,
leading: PageBackButton(shadows: [iconShadow]),
flexibleSpace: FlexibleSpaceBar(
background:
(currentRoom!.type == 1 && currentRoom.background != null)
? CloudImageWidget(file: currentRoom.background!)
: (currentRoom.type == 1 &&
currentRoom.members!.length == 1 &&
currentRoom
.members!
.first
.account
.profile
.background
?.id !=
null)
? CloudImageWidget(
file: currentRoom
.members!
.first
.account
.profile
.background!,
)
: currentRoom.background != null
? CloudImageWidget(
file: currentRoom.background!,
fit: BoxFit.cover,
)
: Container(
color: Theme.of(context).appBarTheme.backgroundColor,
),
title: Text(
(currentRoom.type == 1 && currentRoom.name == null)
? currentRoom.members!
.map((e) => e.account.nick)
.join(', ')
: currentRoom.name!,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => _ChatMemberListSheet(roomId: id),
);
},
),
_ChatRoomActionMenu(id: id, iconShadow: iconShadow),
const Gap(8),
],
),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentRoom.description ?? 'descriptionNone'.tr(),
style: const TextStyle(fontSize: 16),
).padding(all: 24),
const Divider(height: 1),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pin/Unpin Switch
if (isPinned.value != null)
SwitchListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 24),
secondary: Icon(
Symbols.push_pin,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
title: const Text('pinChatRoom').tr(),
subtitle: const Text('pinChatRoomDescription').tr(),
value: isPinned.value!,
onChanged: (value) async {
// Update local state immediately for instant UI feedback
isPinned.value = value;
final db = ref.read(databaseProvider);
await db.toggleChatRoomPinned(id);
// Re-verify the state from database in case of error
final room = await (db.select(
db.chatRooms,
)..where((r) => r.id.equals(id))).getSingleOrNull();
final actualPinned = room?.isPinned ?? false;
if (actualPinned != value) {
// Revert if database operation failed
isPinned.value = actualPinned;
}
showSnackBar(
value
? 'chatRoomPinned'.tr()
: 'chatRoomUnpinned'.tr(),
);
},
),
roomIdentity.when(
data: (identity) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
leading: const Icon(Symbols.notifications),
trailing: const Icon(Symbols.chevron_right),
title: const Text('chatNotifyLevel').tr(),
subtitle: Text(
kNotifyLevelText[identity!.notify].tr(),
),
onTap: () => showNotifyLevelBottomSheet(identity),
),
ListTile(
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
leading: const Icon(Icons.timer),
trailing: const Icon(Symbols.chevron_right),
title: const Text('chatBreak').tr(),
subtitle:
identity.breakUntil != null &&
identity.breakUntil!.isAfter(
DateTime.now(),
)
? Text(
DateFormat(
'yyyy-MM-dd HH:mm',
).format(identity.breakUntil!),
)
: const Text('chatBreakNone').tr(),
onTap: () => showChatBreakDialog(),
),
ListTile(
contentPadding: EdgeInsets.symmetric(
horizontal: 24,
),
leading: const Icon(Icons.search),
trailing: const Icon(Symbols.chevron_right),
title: const Text('searchMessages').tr(),
subtitle: totalMessages.when(
data: (count) => Text(
'messagesCount'.tr(args: [count.toString()]),
),
loading: () =>
const CircularProgressIndicator(),
error: (err, stack) => Text(
'errorGeneric'.tr(args: [err.toString()]),
),
),
onTap: () async {
final result = await context.pushNamed(
'searchMessages',
pathParameters: {'id': id},
);
if (result is SearchMessagesResult) {
// Navigate back to room screen with message to jump to
if (context.mounted) {
context.pop(result);
}
}
},
),
],
),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
],
),
],
),
),
],
),
),
);
}
}
class _ChatRoomActionMenu extends HookConsumerWidget {
final String id;
final Shadow iconShadow;
const _ChatRoomActionMenu({required this.id, required this.iconShadow});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chatIdentity = ref.watch(chatRoomIdentityProvider(id));
final chatRoom = ref.watch(chatRoomProvider(id));
final isManagable =
chatIdentity.value?.accountId == chatRoom.value?.accountId ||
chatRoom.value?.type == 1;
return PopupMenuButton(
icon: Icon(Icons.more_vert, shadows: [iconShadow]),
itemBuilder: (context) => [
if (isManagable)
PopupMenuItem(
onTap: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => EditChatScreen(id: id),
).then((value) {
if (value != null) {
// Invalidate to refresh room data after edit
ref.invalidate(chatMemberListProvider(id));
}
});
},
child: Row(
children: [
Icon(
Icons.edit,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
const Gap(12),
const Text('editChatRoom').tr(),
],
),
),
if (isManagable)
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const Gap(12),
const Text(
'deleteChatRoom',
style: TextStyle(color: Colors.red),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'deleteChatRoomHint'.tr(),
'deleteChatRoom'.tr(),
isDanger: true,
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete('/messager/chat/$id');
ref.invalidate(chatRoomJoinedProvider);
if (context.mounted) {
context.pop();
}
}
});
},
)
else
PopupMenuItem(
child: Row(
children: [
Icon(
Icons.exit_to_app,
color: Theme.of(context).colorScheme.error,
),
const Gap(12),
Text(
'leaveChatRoom',
style: TextStyle(color: Theme.of(context).colorScheme.error),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'leaveChatRoomHint'.tr(),
'leaveChatRoom'.tr(),
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete('/messager/chat/$id/members/me');
ref.invalidate(chatRoomJoinedProvider);
if (context.mounted) {
context.pop();
}
}
});
},
),
],
);
}
}
@freezed
sealed class ChatRoomMemberState with _$ChatRoomMemberState {
const factory ChatRoomMemberState({
required List<SnChatMember> members,
required bool isLoading,
required int total,
String? error,
}) = _ChatRoomMemberState;
}
final chatMemberListProvider = AsyncNotifierProvider.autoDispose.family(
ChatMemberListNotifier.new,
);
class ChatMemberListNotifier
extends AsyncNotifier<PaginationState<SnChatMember>>
with AsyncPaginationController<SnChatMember> {
static const pageSize = 20;
final String arg;
ChatMemberListNotifier(this.arg);
@override
Future<List<SnChatMember>> fetch() async {
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get(
'/messager/chat/$arg/members',
queryParameters: {
'offset': fetchedCount.toString(),
'take': pageSize,
'withStatus': true,
},
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final members = response.data
.map((e) => SnChatMember.fromJson(e))
.cast<SnChatMember>()
.toList();
return members;
}
}
class _ChatMemberListSheet extends HookConsumerWidget {
final String roomId;
const _ChatMemberListSheet({required this.roomId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final memberState = ref.watch(chatMemberListProvider(roomId));
final memberNotifier = ref.watch(chatMemberListProvider(roomId).notifier);
final roomIdentity = ref.watch(chatRoomIdentityProvider(roomId));
final chatRoom = ref.watch(chatRoomProvider(roomId));
final isManagable =
chatRoom.value?.accountId == roomIdentity.value?.accountId ||
chatRoom.value?.type == 1;
Future<void> invitePerson() async {
final result = await showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const AccountPickerSheet(),
);
if (result == null) return;
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.post(
'/messager/chat/invites/$roomId',
data: {'related_user_id': result.id, 'role': 0},
);
memberNotifier.refresh();
} catch (err) {
showErrorAlert(err);
}
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Column(
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Text(
'members'.plural(memberState.value?.totalCount ?? 0),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.person_add),
onPressed: invitePerson,
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
IconButton(
icon: const Icon(Symbols.refresh),
onPressed: () {
memberNotifier.refresh();
},
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: PaginationList(
provider: chatMemberListProvider(roomId),
notifier: chatMemberListProvider(roomId).notifier,
itemBuilder: (context, idx, member) {
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: AccountPfcRegion(
uname: member.account.name,
child: ProfilePictureWidget(
file: member.account.profile.picture,
),
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account.nick)),
if (member.status != null)
AccountStatusLabel(
status: member.status!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Text("@${member.account.name}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isManagable)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeChatMemberHint'.tr(),
'removeChatMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.delete(
'/messager/chat/$roomId/members/${member.accountId}',
);
// Refresh both providers
memberNotifier.refresh();
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,280 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'chat_detail_screen.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatRoomMemberState {
List<SnChatMember> get members; bool get isLoading; int get total; String? get error;
/// Create a copy of ChatRoomMemberState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatRoomMemberStateCopyWith<ChatRoomMemberState> get copyWith => _$ChatRoomMemberStateCopyWithImpl<ChatRoomMemberState>(this as ChatRoomMemberState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatRoomMemberState&&const DeepCollectionEquality().equals(other.members, members)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.total, total) || other.total == total)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(members),isLoading,total,error);
@override
String toString() {
return 'ChatRoomMemberState(members: $members, isLoading: $isLoading, total: $total, error: $error)';
}
}
/// @nodoc
abstract mixin class $ChatRoomMemberStateCopyWith<$Res> {
factory $ChatRoomMemberStateCopyWith(ChatRoomMemberState value, $Res Function(ChatRoomMemberState) _then) = _$ChatRoomMemberStateCopyWithImpl;
@useResult
$Res call({
List<SnChatMember> members, bool isLoading, int total, String? error
});
}
/// @nodoc
class _$ChatRoomMemberStateCopyWithImpl<$Res>
implements $ChatRoomMemberStateCopyWith<$Res> {
_$ChatRoomMemberStateCopyWithImpl(this._self, this._then);
final ChatRoomMemberState _self;
final $Res Function(ChatRoomMemberState) _then;
/// Create a copy of ChatRoomMemberState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? members = null,Object? isLoading = null,Object? total = null,Object? error = freezed,}) {
return _then(_self.copyWith(
members: null == members ? _self.members : members // ignore: cast_nullable_to_non_nullable
as List<SnChatMember>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [ChatRoomMemberState].
extension ChatRoomMemberStatePatterns on ChatRoomMemberState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ChatRoomMemberState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChatRoomMemberState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ChatRoomMemberState value) $default,){
final _that = this;
switch (_that) {
case _ChatRoomMemberState():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ChatRoomMemberState value)? $default,){
final _that = this;
switch (_that) {
case _ChatRoomMemberState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<SnChatMember> members, bool isLoading, int total, String? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatRoomMemberState() when $default != null:
return $default(_that.members,_that.isLoading,_that.total,_that.error);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<SnChatMember> members, bool isLoading, int total, String? error) $default,) {final _that = this;
switch (_that) {
case _ChatRoomMemberState():
return $default(_that.members,_that.isLoading,_that.total,_that.error);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<SnChatMember> members, bool isLoading, int total, String? error)? $default,) {final _that = this;
switch (_that) {
case _ChatRoomMemberState() when $default != null:
return $default(_that.members,_that.isLoading,_that.total,_that.error);case _:
return null;
}
}
}
/// @nodoc
class _ChatRoomMemberState implements ChatRoomMemberState {
const _ChatRoomMemberState({required final List<SnChatMember> members, required this.isLoading, required this.total, this.error}): _members = members;
final List<SnChatMember> _members;
@override List<SnChatMember> get members {
if (_members is EqualUnmodifiableListView) return _members;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_members);
}
@override final bool isLoading;
@override final int total;
@override final String? error;
/// Create a copy of ChatRoomMemberState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChatRoomMemberStateCopyWith<_ChatRoomMemberState> get copyWith => __$ChatRoomMemberStateCopyWithImpl<_ChatRoomMemberState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatRoomMemberState&&const DeepCollectionEquality().equals(other._members, _members)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.total, total) || other.total == total)&&(identical(other.error, error) || other.error == error));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_members),isLoading,total,error);
@override
String toString() {
return 'ChatRoomMemberState(members: $members, isLoading: $isLoading, total: $total, error: $error)';
}
}
/// @nodoc
abstract mixin class _$ChatRoomMemberStateCopyWith<$Res> implements $ChatRoomMemberStateCopyWith<$Res> {
factory _$ChatRoomMemberStateCopyWith(_ChatRoomMemberState value, $Res Function(_ChatRoomMemberState) _then) = __$ChatRoomMemberStateCopyWithImpl;
@override @useResult
$Res call({
List<SnChatMember> members, bool isLoading, int total, String? error
});
}
/// @nodoc
class __$ChatRoomMemberStateCopyWithImpl<$Res>
implements _$ChatRoomMemberStateCopyWith<$Res> {
__$ChatRoomMemberStateCopyWithImpl(this._self, this._then);
final _ChatRoomMemberState _self;
final $Res Function(_ChatRoomMemberState) _then;
/// Create a copy of ChatRoomMemberState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? members = null,Object? isLoading = null,Object? total = null,Object? error = freezed,}) {
return _then(_ChatRoomMemberState(
members: null == members ? _self._members : members // ignore: cast_nullable_to_non_nullable
as List<SnChatMember>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
as bool,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

View File

@@ -0,0 +1,80 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_detail_screen.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(totalMessagesCount)
final totalMessagesCountProvider = TotalMessagesCountFamily._();
final class TotalMessagesCountProvider
extends $FunctionalProvider<AsyncValue<int>, int, FutureOr<int>>
with $FutureModifier<int>, $FutureProvider<int> {
TotalMessagesCountProvider._({
required TotalMessagesCountFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'totalMessagesCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$totalMessagesCountHash();
@override
String toString() {
return r'totalMessagesCountProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<int> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<int> create(Ref ref) {
final argument = this.argument as String;
return totalMessagesCount(ref, argument);
}
@override
bool operator ==(Object other) {
return other is TotalMessagesCountProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$totalMessagesCountHash() =>
r'd55f1507aba2acdce5e468c1c2e15dba7640c571';
final class TotalMessagesCountFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<int>, String> {
TotalMessagesCountFamily._()
: super(
retry: null,
name: r'totalMessagesCountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
TotalMessagesCountProvider call(String roomId) =>
TotalMessagesCountProvider._(argument: roomId, from: this);
@override
String toString() => r'totalMessagesCountProvider';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/chat_room.dart';
import 'package:island/chat/chat_widgets/chat_room_list_tile.dart';
import 'package:island/core/network.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/realms/realm/realms.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
class ChatInvitesSheet extends HookConsumerWidget {
const ChatInvitesSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final invites = ref.watch(chatroomInvitesProvider);
Future<void> acceptInvite(SnChatMember invite) async {
try {
final client = ref.read(apiClientProvider);
await client.post(
'/messager/chat/invites/${invite.chatRoom!.id}/accept',
);
ref.invalidate(chatroomInvitesProvider);
ref.invalidate(chatRoomJoinedProvider);
} catch (err) {
showErrorAlert(err);
}
}
Future<void> declineInvite(SnChatMember invite) async {
try {
final client = ref.read(apiClientProvider);
await client.post(
'/messager/chat/invites/${invite.chatRoom!.id}/decline',
);
ref.invalidate(chatroomInvitesProvider);
} catch (err) {
showErrorAlert(err);
}
}
return SheetScaffold(
titleText: 'invites'.tr(),
actions: [
IconButton(
icon: const Icon(Symbols.refresh),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
onPressed: () {
ref.invalidate(realmInvitesProvider);
},
),
],
child: invites.when(
data: (items) => items.isEmpty
? Center(
child: Text('invitesEmpty', textAlign: TextAlign.center).tr(),
)
: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final invite = items[index];
return ChatRoomListTile(
room: invite.chatRoom!,
isDirect: invite.chatRoom!.type == 1,
subtitle: Row(
spacing: 6,
children: [
if (invite.chatRoom!.type == 1)
Badge(
label: const Text('directMessage').tr(),
backgroundColor: Theme.of(
context,
).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/core/network.dart';
import 'package:island/pagination/pagination.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/shared/widgets/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
final chatCloudFileListNotifierProvider = AsyncNotifierProvider.autoDispose(
ChatCloudFileListNotifier.new,
);
class ChatCloudFileListNotifier
extends AsyncNotifier<PaginationState<SnCloudFile>>
with AsyncPaginationController<SnCloudFile> {
@override
Future<List<SnCloudFile>> fetch() async {
final client = ref.read(apiClientProvider);
final take = 20;
final queryParameters = {'offset': fetchedCount, 'take': take};
final response = await client.get(
'/drive/files/me',
queryParameters: queryParameters,
);
final List<SnCloudFile> items = (response.data as List)
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList();
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
return items;
}
}
class ChatLinkAttachment extends HookConsumerWidget {
const ChatLinkAttachment({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final idController = useTextEditingController();
final errorMessage = useState<String?>(null);
return SheetScaffold(
heightFactor: 0.6,
titleText: 'linkAttachment'.tr(),
child: DefaultTabController(
length: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TabBar(
tabs: [
Tab(text: 'attachmentsRecentUploads'.tr()),
Tab(text: 'attachmentsManualInput'.tr()),
],
),
Expanded(
child: TabBarView(
children: [
PaginationList(
provider: chatCloudFileListNotifierProvider,
notifier: chatCloudFileListNotifierProvider.notifier,
padding: EdgeInsets.only(top: 8),
itemBuilder: (context, index, item) {
final itemType = item.mimeType?.split('/').firstOrNull;
return ListTile(
leading: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: SizedBox(
height: 48,
width: 48,
child: switch (itemType) {
'image' => CloudImageWidget(file: item),
'audio' => const Icon(
Symbols.audio_file,
fill: 1,
).center(),
'video' => const Icon(
Symbols.video_file,
fill: 1,
).center(),
_ => const Icon(
Symbols.body_system,
fill: 1,
).center(),
},
),
),
title: item.name.isEmpty
? Text('untitled').tr().italic()
: Text(item.name),
onTap: () {
Navigator.pop(context, item);
},
);
},
),
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: idController,
decoration: InputDecoration(
labelText: 'fileId'.tr(),
helperText: 'fileIdHint'.tr(),
helperMaxLines: 3,
errorText: errorMessage.value,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(16),
InkWell(
child: Text(
'fileIdLinkHint',
).tr().fontSize(13).opacity(0.85),
onTap: () {
launchUrlString('https://fs.solian.app');
},
).padding(horizontal: 14),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: const Icon(Symbols.add),
label: Text('add'.tr()),
onPressed: () async {
final fileId = idController.text.trim();
if (fileId.isEmpty) {
errorMessage.value = 'fileIdCannotBeEmpty'.tr();
return;
}
try {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/drive/files/$fileId/info',
);
final SnCloudFile cloudFile =
SnCloudFile.fromJson(response.data);
if (context.mounted) {
Navigator.of(context).pop(cloudFile);
}
} catch (e) {
errorMessage.value = 'failedToFetchFile'.tr(
args: [e.toString()],
);
}
},
),
),
],
).padding(horizontal: 24, vertical: 24),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,623 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/accounts/accounts_widgets/account/account_picker.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/chat_room.dart';
import 'package:island/chat/chat_pod/chat_summary.dart';
import 'package:island/chat/chat_widgets/chat_invites_sheet.dart';
import 'package:island/chat/chat_widgets/chat_room_form.dart';
import 'package:island/chat/chat_widgets/chat_room_list_tile.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/core/services/event_bus.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/shared/widgets/extended_refresh_indicator.dart';
import 'package:island/shared/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class ChatListBodyWidget extends HookConsumerWidget {
final bool isFloating;
final TabController tabController;
final ValueNotifier<int> selectedTab;
const ChatListBodyWidget({
super.key,
this.isFloating = false,
required this.tabController,
required this.selectedTab,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chats = ref.watch(chatRoomJoinedProvider);
final settings = ref.watch(appSettingsProvider);
Widget bodyWidget = Column(
children: [
Consumer(
builder: (context, ref, _) {
final summaryState = ref.watch(chatSummaryProvider);
return summaryState.maybeWhen(
loading: () => const LinearProgressIndicator(
minHeight: 2,
borderRadius: BorderRadius.zero,
),
orElse: () => const SizedBox.shrink(),
);
},
),
Expanded(
child: chats.when(
data: (items) {
final filteredItems = items.where(
(item) =>
selectedTab.value == 0 ||
(selectedTab.value == 1 && item.type == 1) ||
(selectedTab.value == 2 && item.type != 1),
);
final pinnedItems = filteredItems
.where((item) => item.isPinned)
.toList();
final unpinnedItems = filteredItems
.where((item) => !item.isPinned)
.toList();
return ExtendedRefreshIndicator(
onRefresh: () => Future.sync(() {
ref.invalidate(chatRoomJoinedProvider);
}),
child: Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: Column(
children: [
// Always show pinned chats in their own section
if (pinnedItems.isNotEmpty)
ExpansionTile(
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.5),
collapsedBackgroundColor: Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.5),
title: Text('pinnedChatRoom'.tr()),
leading: const Icon(Symbols.keep, fill: 1),
tilePadding: const EdgeInsets.symmetric(
horizontal: 24,
),
initiallyExpanded: true,
children: [
for (final item in pinnedItems)
ChatRoomListTile(
room: item,
isDirect: item.type == 1,
onTap: () {
if (isWideScreen(context)) {
context.replaceNamed(
'chatRoom',
pathParameters: {'id': item.id},
);
} else {
context.pushNamed(
'chatRoom',
pathParameters: {'id': item.id},
);
}
},
),
],
),
Expanded(
child: Consumer(
builder: (context, ref, _) {
final summaries =
ref
.watch(chatSummaryProvider)
.whenData((data) => data)
.value ??
{};
if (settings.groupedChatList &&
selectedTab.value == 0) {
// Group by realm (include both pinned and unpinned)
final realmGroups = <String?, List<SnChatRoom>>{};
final ungrouped = <SnChatRoom>[];
for (final item in filteredItems) {
if (item.realmId != null) {
realmGroups
.putIfAbsent(item.realmId, () => [])
.add(item);
} else if (!item.isPinned) {
// Only unpinned chats without realm go to ungrouped
ungrouped.add(item);
}
}
final children = <Widget>[];
// Add realm groups
for (final entry in realmGroups.entries) {
final rooms = entry.value;
final realm = rooms.first.realm;
final realmName =
realm?.name ?? 'Unknown Realm';
// Calculate total unread count for this realm
final totalUnread = rooms.fold<int>(
0,
(sum, room) =>
sum +
(summaries[room.id]?.unreadCount ?? 0),
);
children.add(
ExpansionTile(
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withOpacity(0.5),
collapsedBackgroundColor:
Colors.transparent,
title: Row(
children: [
Expanded(child: Text(realmName)),
Badge(
isLabelVisible: totalUnread > 0,
label: Text(totalUnread.toString()),
backgroundColor: Theme.of(
context,
).colorScheme.primary,
textColor: Theme.of(
context,
).colorScheme.onPrimary,
),
],
),
leading: ProfilePictureWidget(
file: realm?.picture,
radius: 16,
),
tilePadding: const EdgeInsets.only(
left: 20,
right: 24,
),
children: rooms.map((room) {
return ChatRoomListTile(
room: room,
isDirect: room.type == 1,
onTap: () {
if (isWideScreen(context)) {
context.replaceNamed(
'chatRoom',
pathParameters: {'id': room.id},
);
} else {
context.pushNamed(
'chatRoom',
pathParameters: {'id': room.id},
);
}
},
);
}).toList(),
),
);
}
// Add ungrouped chats
if (ungrouped.isNotEmpty) {
children.addAll(
ungrouped.map((room) {
return ChatRoomListTile(
room: room,
isDirect: room.type == 1,
onTap: () {
if (isWideScreen(context)) {
context.replaceNamed(
'chatRoom',
pathParameters: {'id': room.id},
);
} else {
context.pushNamed(
'chatRoom',
pathParameters: {'id': room.id},
);
}
},
);
}),
);
}
return ListView(
padding: EdgeInsets.only(bottom: 96),
children: children,
);
} else {
// Normal list view
return SuperListView.builder(
padding: EdgeInsets.only(bottom: 96),
itemCount: unpinnedItems
.where(
(item) =>
selectedTab.value == 0 ||
(selectedTab.value == 1 &&
item.type == 1) ||
(selectedTab.value == 2 &&
item.type != 1),
)
.length,
itemBuilder: (context, index) {
final item = unpinnedItems[index];
return ChatRoomListTile(
room: item,
isDirect: item.type == 1,
onTap: () {
if (isWideScreen(context)) {
context.replaceNamed(
'chatRoom',
pathParameters: {'id': item.id},
);
} else {
context.pushNamed(
'chatRoom',
pathParameters: {'id': item.id},
);
}
},
);
},
);
}
},
),
),
],
),
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => ResponseErrorWidget(
error: error,
onRetry: () {
ref.invalidate(chatRoomJoinedProvider);
},
),
),
),
],
);
return isFloating ? Card(child: bodyWidget) : bodyWidget;
}
}
class ChatShellScreen extends HookConsumerWidget {
final Widget child;
const ChatShellScreen({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
if (isWide) {
return AppBackground(
isRoot: true,
child: Row(
children: [
Flexible(
flex: 2,
child: ChatListScreen(
isAside: true,
isFloating: true,
).padding(left: 16, vertical: 16),
),
const Gap(8),
Flexible(
flex: 4,
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
),
child: child,
).padding(top: 16),
),
],
),
);
}
return AppBackground(isRoot: true, child: child);
}
}
class ChatFabWidget extends HookConsumerWidget {
const ChatFabWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value == null) {
return const SizedBox.shrink();
}
return FloatingActionButton(
child: const Icon(Symbols.add),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Gap(40),
ListTile(
title: const Text('createChatRoom').tr(),
leading: const Icon(Symbols.add),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const EditChatScreen(),
).then((value) {
if (value != null) {
eventBus.fire(const ChatRoomsRefreshEvent());
}
});
},
),
ListTile(
title: const Text('createDirectMessage').tr(),
leading: const Icon(Symbols.person),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () async {
final result = await showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const AccountPickerSheet(),
);
if (result == null) return;
final client = ref.read(apiClientProvider);
try {
await client.post(
'/messager/chat/direct',
data: {'related_user_id': result.id},
);
eventBus.fire(const ChatRoomsRefreshEvent());
} catch (err) {
showErrorAlert(err);
}
},
),
const Gap(16),
],
),
);
},
).padding(bottom: MediaQuery.of(context).padding.bottom);
}
}
class ChatListScreen extends HookConsumerWidget {
final bool isAside;
final bool isFloating;
const ChatListScreen({
super.key,
this.isAside = false,
this.isFloating = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
final chatInvites = ref.watch(chatroomInvitesProvider);
final tabController = useTabController(initialLength: 3);
final selectedTab = useState(
0,
); // 0 for All, 1 for Direct Messages, 2 for Group Chats
useEffect(() {
tabController.addListener(() {
selectedTab.value = tabController.index;
});
// Listen for chat rooms refresh events
final subscription = eventBus.on<ChatRoomsRefreshEvent>().listen((event) {
ref.invalidate(chatRoomJoinedProvider);
});
return () {
subscription.cancel();
};
}, [tabController]);
if (isAside) {
return Card(
margin: EdgeInsets.zero,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Stack(
children: [
Column(
children: [
Row(
children: [
Expanded(
child: TabBar(
dividerColor: Colors.transparent,
controller: tabController,
tabAlignment: TabAlignment.start,
isScrollable: true,
tabs: [
const Tab(icon: Icon(Symbols.chat)),
const Tab(icon: Icon(Symbols.person)),
const Tab(icon: Icon(Symbols.group)),
],
),
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Badge(
label: Text(
chatInvites.when(
data: (invites) => invites.length.toString(),
error: (_, _) => '0',
loading: () => '0',
),
),
isLabelVisible: chatInvites.when(
data: (invites) => invites.isNotEmpty,
error: (_, _) => false,
loading: () => false,
),
child: const Icon(Symbols.email),
),
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const ChatInvitesSheet(),
);
},
),
),
],
).padding(horizontal: 8),
const Divider(height: 1),
Expanded(
child: ChatListBodyWidget(
isFloating: false,
tabController: tabController,
selectedTab: selectedTab,
),
),
],
),
Positioned(bottom: 16, right: 16, child: ChatFabWidget()),
],
),
),
);
}
if (isWide && !isAside) {
return const EmptyPageHolder();
}
final appbarFeColor = Theme.of(context).appBarTheme.foregroundColor;
final userInfo = ref.watch(userInfoProvider);
return AppScaffold(
extendBody: false, // Prevent conflicts with tabs navigation
floatingActionButton: const ChatFabWidget(),
appBar: AppBar(
flexibleSpace: Container(
height: 48,
margin: EdgeInsets.only(
left: 8,
right: 8,
top: 4 + MediaQuery.of(context).padding.top,
bottom: 4,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: Row(
spacing: 8,
children: [
IconButton(
icon: Icon(
Symbols.inbox,
fill: tabController.index == 0 ? 1 : 0,
),
color: appbarFeColor,
onPressed: () => tabController.animateTo(0),
tooltip: 'chatTabAll'.tr(),
),
IconButton(
icon: Icon(
Symbols.person,
fill: tabController.index == 1 ? 1 : 0,
),
color: appbarFeColor,
onPressed: () => tabController.animateTo(1),
tooltip: 'chatTabDirect'.tr(),
),
IconButton(
icon: Icon(
Symbols.group,
fill: tabController.index == 2 ? 1 : 0,
),
color: appbarFeColor,
onPressed: () => tabController.animateTo(2),
tooltip: 'chatTabGroup'.tr(),
),
],
),
),
IconButton(
icon: Badge(
label: Text(
chatInvites.when(
data: (invites) => invites.length.toString(),
error: (_, _) => '0',
loading: () => '0',
),
),
isLabelVisible: chatInvites.when(
data: (invites) => invites.isNotEmpty,
error: (_, _) => false,
loading: () => false,
),
child: const Icon(Symbols.email),
),
color: appbarFeColor,
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const ChatInvitesSheet(),
);
},
),
],
),
),
),
),
body: userInfo.value == null
? const ResponseUnauthorizedWidget()
: ChatListBodyWidget(
isFloating: false,
tabController: tabController,
selectedTab: selectedTab,
),
);
}
}

View File

@@ -0,0 +1,303 @@
import 'package:collection/collection.dart';
import 'package:croppy/croppy.dart' hide cropImage;
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/chat_room.dart';
import 'package:island/core/network.dart';
import 'package:island/core/services/image.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/realms/realm/realms.dart';
import 'package:island/realms/realms_models/realm.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class NewChatScreen extends StatelessWidget {
const NewChatScreen({super.key});
@override
Widget build(BuildContext context) {
return const EditChatScreen();
}
}
class EditChatScreen extends HookConsumerWidget {
final String? id;
const EditChatScreen({super.key, this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>(), []);
final submitting = useState(false);
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final picture = useState<SnCloudFile?>(null);
final background = useState<SnCloudFile?>(null);
final isPublic = useState(true);
final isCommunity = useState(false);
final chat = ref.watch(chatRoomProvider(id));
final joinedRealms = ref.watch(realmsJoinedProvider);
final currentRealm = useState<SnRealm?>(null);
useEffect(() {
if (chat.value != null) {
nameController.text = chat.value!.name ?? '';
descriptionController.text = chat.value!.description ?? '';
picture.value = chat.value!.picture;
background.value = chat.value!.background;
isPublic.value = chat.value!.isPublic;
isCommunity.value = chat.value!.isCommunity;
currentRealm.value = joinedRealms.value?.firstWhereOrNull(
(realm) => realm.id == chat.value!.realmId,
);
}
return;
}, [chat, joinedRealms]);
void setPicture(String position) async {
showLoadingModal(context);
var result = await ref
.read(imagePickerProvider)
.pickImage(source: ImageSource.gallery);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
hideLoadingModal(context);
result = await cropImage(
context,
image: result,
allowedAspectRatios: [
if (position == 'background')
const CropAspectRatio(height: 7, width: 16)
else
const CropAspectRatio(height: 1, width: 1),
],
);
if (result == null) {
if (context.mounted) hideLoadingModal(context);
return;
}
if (!context.mounted) return;
showLoadingModal(context);
submitting.value = true;
try {
final cloudFile = await FileUploader.createCloudFile(
ref: ref,
fileData: UniversalFile(data: result, type: UniversalFileType.image),
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
}
switch (position) {
case 'picture':
picture.value = cloudFile;
case 'background':
background.value = cloudFile;
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
submitting.value = false;
}
}
Future<void> performAction() async {
if (!formKey.currentState!.validate()) return;
submitting.value = true;
try {
final client = ref.watch(apiClientProvider);
final resp = await client.request(
id == null ? '/messager/chat' : '/messager/chat/$id',
data: {
'name': nameController.text,
'description': descriptionController.text,
'background_id': background.value?.id,
'picture_id': picture.value?.id,
'realm_id': currentRealm.value?.id,
'is_public': isPublic.value,
'is_community': isCommunity.value,
},
options: Options(method: id == null ? 'POST' : 'PATCH'),
);
if (context.mounted) {
context.pop(SnChatRoom.fromJson(resp.data));
}
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return SheetScaffold(
titleText: (id == null ? 'createChatRoom' : 'editChatRoom').tr(),
onClose: () => context.pop(),
child: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
GestureDetector(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: background.value != null
? CloudFileWidget(
item: background.value!,
fit: BoxFit.cover,
)
: const SizedBox.shrink(),
),
onTap: () {
setPicture('background');
},
),
Positioned(
left: 20,
bottom: -32,
child: GestureDetector(
child: ProfilePictureWidget(
file: picture.value,
radius: 40,
fallbackIcon: Symbols.group,
),
onTap: () {
setPicture('picture');
},
),
),
],
),
).padding(bottom: 32),
Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
minLines: 3,
maxLines: null,
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
DropdownButtonFormField<SnRealm>(
value: currentRealm.value,
decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: [
DropdownMenuItem<SnRealm>(
value: null,
child: Text('none'.tr()),
),
...joinedRealms.maybeWhen(
data: (realms) => realms.map(
(realm) => DropdownMenuItem(
value: realm,
child: Text(realm.name),
),
),
orElse: () => [],
),
],
onChanged: joinedRealms.isLoading
? null
: (SnRealm? value) {
currentRealm.value = value;
},
),
const SizedBox(height: 16),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
CheckboxListTile(
secondary: const Icon(Symbols.public),
title: Text('publicChat').tr(),
subtitle: Text('publicChatDescription').tr(),
value: isPublic.value,
onChanged: (value) {
isPublic.value = value ?? true;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
CheckboxListTile(
secondary: const Icon(Symbols.travel_explore),
title: Text('communityChat').tr(),
subtitle: Text('communityChatDescription').tr(),
value: isCommunity.value,
onChanged: (value) {
isCommunity.value = value ?? false;
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
],
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: submitting.value ? null : performAction,
label: const Text('Save'),
icon: const Icon(Symbols.save),
),
),
],
).padding(all: 24),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/chat_summary.dart';
import 'package:island/chat/chat_widgets/chat_room_widgets.dart';
class ChatRoomListTile extends HookConsumerWidget {
final SnChatRoom room;
final bool isDirect;
final Widget? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
const ChatRoomListTile({
super.key,
required this.room,
this.isDirect = false,
this.subtitle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref
.watch(chatSummaryProvider)
.whenData((summaries) => summaries[room.id]);
var validMembers = room.members ?? [];
if (validMembers.isNotEmpty) {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value != null) {
validMembers = validMembers
.where((e) => e.accountId != userInfo.value!.id)
.toList();
}
}
String titleText;
if (isDirect && room.name == null) {
if (room.members?.isNotEmpty ?? false) {
titleText = validMembers.map((e) => e.account.nick).join(', ');
} else {
titleText = 'Direct Message';
}
} else {
titleText = room.name ?? '';
}
return ListTile(
leading: ChatRoomAvatar(
room: room,
isDirect: isDirect,
summary: summary,
validMembers: validMembers,
),
title: Text(titleText),
subtitle: ChatRoomSubtitle(
room: room,
isDirect: isDirect,
validMembers: validMembers,
summary: summary,
subtitle: subtitle,
),
trailing: trailing, // Add this line
onTap: () async {
// Clear unread count if there are unread messages
ref.read(chatSummaryProvider.future).then((summary) {
if ((summary[room.id]?.unreadCount ?? 0) > 0) {
ref.read(chatSummaryProvider.notifier).clearUnreadCount(room.id);
}
});
onTap?.call();
},
);
}
}

View File

@@ -0,0 +1,491 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_pod/chat_online_count.dart';
import 'package:island/chat/chat_pod/chat_room.dart';
import 'package:island/chat/chat_widgets/call_button.dart';
import 'package:island/chat/chat_widgets/chat_input.dart';
import 'package:island/chat/chat_widgets/chat_search_screen.dart';
import 'package:island/chat/chat_widgets/public_room_preview.dart';
import 'package:island/chat/chat_widgets/room_app_bar.dart';
import 'package:island/chat/chat_widgets/room_message_list.dart';
import 'package:island/chat/chat_widgets/room_overlays.dart';
import 'package:island/chat/chat_widgets/room_selection_mode.dart';
import 'package:island/chat/hooks/use_room_file_picker.dart';
import 'package:island/chat/hooks/use_room_input.dart';
import 'package:island/chat/hooks/use_room_scroll.dart';
import 'package:island/chat/messages_notifier.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/core/services/analytics_service.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/shared/widgets/attachment_uploader.dart';
import 'package:island/shared/widgets/response.dart';
import 'package:island/thought/thought/think_sheet.dart';
import 'package:styled_widget/styled_widget.dart';
class ChatRoomScreen extends HookConsumerWidget {
final String id;
const ChatRoomScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mediaQuery = MediaQuery.of(context);
final chatRoom = ref.watch(chatRoomProvider(id));
final chatIdentity = ref.watch(chatRoomIdentityProvider(id));
final isSyncing = ref.watch(chatSyncingProvider);
final onlineCount = ref.watch(chatOnlineCountProvider(id));
final settings = ref.watch(appSettingsProvider);
useEffect(() {
if (!chatRoom.isLoading && chatRoom.value != null) {
AnalyticsService().logChatRoomOpened(
id,
chatRoom.value!.isCommunity == true ? 'group' : 'direct',
);
}
return null;
}, [chatRoom]);
if (chatIdentity.isLoading || chatRoom.isLoading) {
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: const Center(child: CircularProgressIndicator()),
);
} else if (chatIdentity.value == null) {
return chatRoom.when(
data: (room) {
if (room!.isPublic) {
return PublicRoomPreview(id: id, room: room);
} else {
return AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
room.isCommunity == true
? Icons.person_add
: Icons.person_remove,
size: 36,
fill: 1,
).padding(bottom: 4),
Text('chatNotJoined').tr(),
if (room.isCommunity != true)
Text(
'chatUnableJoin',
textAlign: TextAlign.center,
).tr().bold()
else
FilledButton.tonalIcon(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.read(apiClientProvider);
await apiClient.post(
'/messager/chat/${room.id}/members/me',
);
ref.invalidate(chatRoomIdentityProvider(id));
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) {
hideLoadingModal(context);
}
}
},
label: Text('chatJoin').tr(),
icon: const Icon(Icons.add),
).padding(top: 8),
],
),
).center(),
),
);
}
},
loading: () => AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: const Center(child: CircularProgressIndicator()),
),
error: (error, _) => AppScaffold(
appBar: AppBar(leading: const PageBackButton()),
body: ResponseErrorWidget(
error: error,
onRetry: () => ref.refresh(chatRoomProvider(id)),
),
),
);
}
final messages = ref.watch(messagesProvider(id));
final messagesNotifier = ref.read(messagesProvider(id).notifier);
final compactHeader = isWideScreen(context);
final scrollManager = useRoomScrollManager(
ref,
id,
messagesNotifier.jumpToMessage,
messages,
);
final inputKey = useMemoized(() => GlobalKey(), []);
final inputHeight = useState<double>(80.0);
final inputManager = useRoomInputManager(ref, id);
final roomOpenTime = useMemoized(() => DateTime.now());
final previousInputHeightRef = useRef<double?>(null);
useEffect(() {
previousInputHeightRef.value = inputHeight.value;
return null;
}, [inputHeight.value]);
useEffect(() {
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
final renderBox =
inputKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final newHeight = renderBox.size.height;
if (newHeight != inputHeight.value) {
inputHeight.value = newHeight;
}
}
});
return timer.cancel;
}, []);
final isSelectionMode = useState<bool>(false);
final selectedMessages = useState<Set<String>>({});
void toggleSelectionMode() {
isSelectionMode.value = !isSelectionMode.value;
if (!isSelectionMode.value) {
selectedMessages.value = {};
}
}
void toggleMessageSelection(String messageId) {
final newSelection = Set<String>.from(selectedMessages.value);
if (newSelection.contains(messageId)) {
newSelection.remove(messageId);
} else {
newSelection.add(messageId);
}
selectedMessages.value = newSelection;
}
void openThinkingSheet() {
if (selectedMessages.value.isEmpty) return;
final selectedMessageData =
messages.value
?.where((msg) => selectedMessages.value.contains(msg.id))
.map(
(msg) => {
'id': msg.id,
'content': msg.content,
'senderId': msg.senderId,
'createdAt': msg.createdAt.toIso8601String(),
'attachments': msg.attachments,
},
)
.toList() ??
[];
ThoughtSheet.show(
context,
attachedMessages: selectedMessageData,
attachedPosts: [],
);
toggleSelectionMode();
}
Future<void> uploadAttachment(int index) async {
final attachment = inputManager.attachments[index];
if (attachment.isOnCloud) return;
final config = await showModalBottomSheet<AttachmentUploadConfig>(
context: context,
isScrollControlled: true,
builder: (context) => AttachmentUploaderSheet(
ref: ref,
attachments: inputManager.attachments,
index: index,
),
);
if (config == null) return;
try {
inputManager.updateAttachmentProgress('chat-upload', 0);
final cloudFile = await FileUploader.createCloudFile(
ref: ref,
fileData: attachment,
poolId: config.poolId,
mode: attachment.type == UniversalFileType.file
? FileUploadMode.generic
: FileUploadMode.mediaSafe,
onProgress: (progress, _) {
inputManager.updateAttachmentProgress(
'chat-upload',
progress ?? 0.0,
);
},
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload file...');
}
final clone = List.of(inputManager.attachments);
clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
inputManager.updateAttachments(clone);
} catch (err) {
showErrorAlert(err.toString());
} finally {
final newProgress = Map<String, Map<int, double?>>.from(
inputManager.attachmentProgress,
);
newProgress.remove('chat-upload');
}
}
final filePicker = useRoomFilePicker(
context,
inputManager.attachments,
inputManager.updateAttachments,
);
void onJump(String messageId) {
messages.when(
data: (messageList) {
scrollManager.scrollToMessage(
messageId: messageId,
messageList: messageList,
);
},
loading: () {},
error: (_, _) {},
);
}
return AppScaffold(
appBar: AppBar(
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
automaticallyImplyLeading: false,
toolbarHeight: compactHeader ? null : 74,
title: chatRoom.when(
data: (room) => RoomAppBar(
room: room!,
onlineCount: onlineCount.value ?? 0,
compact: compactHeader,
),
loading: () => const Text('Loading...'),
error: (err, _) => ResponseErrorWidget(
error: err,
onRetry: () => messagesNotifier.loadInitial(),
),
),
actions: [
chatRoom.when(
data: (data) => AudioCallButton(room: data!),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () async {
final result = await context.pushNamed(
'chatDetail',
pathParameters: {'id': id},
);
if (result is SearchMessagesResult && messages.value != null) {
final messageId = result.messageId;
messagesNotifier.jumpToMessage(messageId).then((index) {
if (index != -1 && context.mounted) {
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.union({messageId}));
messages.when(
data: (messageList) {
scrollManager.scrollToMessage(
messageId: messageId,
messageList: messageList,
);
},
loading: () {},
error: (_, _) {},
);
}
});
}
},
),
const SizedBox(width: 8),
],
),
body: Stack(
children: [
Positioned.fill(
child: Column(
children: [
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder:
(Widget child, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: child,
);
},
child: messages.when(
data: (messageList) => messageList.isEmpty
? Center(
key: const ValueKey('empty-messages'),
child: Text('No messages yet'.tr()),
)
: RoomMessageList(
key: const ValueKey('message-list'),
messages: messageList,
roomAsync: chatRoom,
chatIdentity: chatIdentity,
scrollController: scrollManager.scrollController,
listController: scrollManager.listController,
isSelectionMode: isSelectionMode.value,
selectedMessages: selectedMessages.value,
toggleSelectionMode: toggleSelectionMode,
toggleMessageSelection: toggleMessageSelection,
onMessageAction: inputManager.onMessageAction,
onJump: onJump,
attachmentProgress:
inputManager.attachmentProgress,
inputHeight: inputHeight.value,
previousInputHeight: previousInputHeightRef.value,
roomOpenTime: roomOpenTime,
disableAnimation: settings.disableAnimation,
),
loading: () => const Center(
key: ValueKey('loading-messages'),
child: CircularProgressIndicator(),
),
error: (error, _) => ResponseErrorWidget(
key: const ValueKey('error-messages'),
error: error,
onRetry: () => messagesNotifier.loadInitial(),
),
),
),
),
],
),
),
RoomOverlays(
roomAsync: chatRoom,
isSyncing: isSyncing,
showGradient: !isSelectionMode.value,
bottomGradientOpacity: scrollManager.bottomGradientOpacity.value,
inputHeight: inputHeight.value,
),
if (!isSelectionMode.value)
Positioned(
left: 0,
right: 0,
bottom: mediaQuery.padding.bottom,
child: chatRoom.when(
data: (room) => room != null
? ChatInput(
key: inputKey,
messageController: inputManager.messageController,
chatRoom: room,
onSend: () => inputManager.sendMessage(ref),
onClear: () {
if (inputManager.messageEditingTo != null) {
inputManager.clearAttachmentsOnly();
}
inputManager.setEditingTo(null);
inputManager.setReplyingTo(null);
inputManager.setForwardingTo(null);
inputManager.setPoll(null);
inputManager.setFund(null);
},
messageEditingTo: inputManager.messageEditingTo,
messageReplyingTo: inputManager.messageReplyingTo,
messageForwardingTo: inputManager.messageForwardingTo,
selectedPoll: inputManager.selectedPoll,
onPollSelected: (poll) => inputManager.setPoll(poll),
selectedFund: inputManager.selectedFund,
onFundSelected: (fund) => inputManager.setFund(fund),
onPickFile: (isPhoto) {
if (isPhoto) {
filePicker.pickPhotos();
} else {
filePicker.pickVideos();
}
},
onPickAudio: filePicker.pickAudio,
onPickGeneralFile: filePicker.pickFiles,
onLinkAttachment: filePicker.linkAttachment,
attachments: inputManager.attachments,
onUploadAttachment: uploadAttachment,
onDeleteAttachment: (index) async {
final attachment = inputManager.attachments[index];
if (attachment.isOnCloud && !attachment.isLink) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/drive/files/${attachment.data.id}',
);
}
final clone = List.of(inputManager.attachments);
clone.removeAt(index);
inputManager.updateAttachments(clone);
},
onMoveAttachment: (idx, delta) {
if (idx + delta < 0 ||
idx + delta >= inputManager.attachments.length) {
return;
}
final clone = List.of(inputManager.attachments);
clone.insert(idx + delta, clone.removeAt(idx));
inputManager.updateAttachments(clone);
},
onAttachmentsChanged: inputManager.updateAttachments,
attachmentProgress: inputManager.attachmentProgress,
)
: const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
),
if (isSelectionMode.value)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: RoomSelectionMode(
visible: isSelectionMode.value,
selectedCount: selectedMessages.value.length,
onClose: toggleSelectionMode,
onAIThink: openThinkingSheet,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:relative_time/relative_time.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:easy_localization/easy_localization.dart';
class ChatRoomAvatar extends StatelessWidget {
final SnChatRoom room;
final bool isDirect;
final AsyncValue<SnChatSummary?> summary;
final List<SnChatMember> validMembers;
const ChatRoomAvatar({
super.key,
required this.room,
required this.isDirect,
required this.summary,
required this.validMembers,
});
@override
Widget build(BuildContext context) {
final avatarChild = (isDirect && room.picture == null)
? SplitAvatarWidget(
files: validMembers.map((e) => e.account.profile.picture).toList(),
)
: room.picture == null
? CircleAvatar(child: Text((room.name ?? 'DM')[0].toUpperCase()))
: ProfilePictureWidget(file: room.picture);
final badgeChild = Badge(
isLabelVisible: summary.when(
data: (data) => (data?.unreadCount ?? 0) > 0,
loading: () => false,
error: (_, _) => false,
),
child: avatarChild,
);
// Show realm avatar as small overlay if chat belongs to a realm
if (room.realm != null) {
return Stack(
children: [
badgeChild,
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.25),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ClipOval(
child: ProfilePictureWidget(file: room.realm!.picture),
),
),
),
],
);
}
return badgeChild;
}
}
class ChatRoomSubtitle extends StatelessWidget {
final SnChatRoom room;
final bool isDirect;
final List<SnChatMember> validMembers;
final AsyncValue<SnChatSummary?> summary;
final Widget? subtitle;
const ChatRoomSubtitle({
super.key,
required this.room,
required this.isDirect,
required this.validMembers,
required this.summary,
this.subtitle,
});
@override
Widget build(BuildContext context) {
if (subtitle != null) return subtitle!;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: [...previousChildren, ?currentChild],
),
child: summary.when(
data: (data) => Container(
key: const ValueKey('data'),
child: data == null
? isDirect && room.description == null
? Text(
validMembers
.map((e) => '@${e.account.name}')
.join(', '),
maxLines: 1,
)
: Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
)
: 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,
),
),
if (data.lastMessage == null)
Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
)
else
Row(
spacing: 4,
children: [
Badge(
label: Text(data.lastMessage!.sender.account.nick),
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
),
Expanded(
child: Text(
(data.lastMessage!.content?.isNotEmpty ?? false)
? data.lastMessage!.content!
: 'messageNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
RelativeTime(
context,
).format(data.lastMessage!.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
),
),
loading: () => Container(
key: const ValueKey('loading'),
child: Builder(
builder: (context) {
final seed = DateTime.now().microsecondsSinceEpoch;
final len = 4 + (seed % 17); // 4..20 inclusive
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
var s = seed;
final buffer = StringBuffer();
for (var i = 0; i < len; i++) {
s = (s * 1103515245 + 12345) & 0x7fffffff;
buffer.write(chars[s % chars.length]);
}
return Skeletonizer(
enabled: true,
child: Text(buffer.toString()),
);
},
),
),
error: (_, _) => Container(
key: const ValueKey('error'),
child: isDirect && room.description == null
? Text(
validMembers.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1),
),
),
);
}
}

View File

@@ -0,0 +1,492 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_pod/chat_room.dart';
import 'package:island/chat/chat_widgets/message_list_tile.dart';
import 'package:island/chat/messages_notifier.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class SearchMessagesResult {
final String messageId;
const SearchMessagesResult(this.messageId);
}
// Search states for better UX
enum SearchState { idle, searching, results, noResults, error }
class _SearchFilters extends StatelessWidget {
final ValueNotifier<bool> withLinks;
final ValueNotifier<bool> withAttachments;
final void Function(String) performSearch;
final TextEditingController searchController;
final bool isLarge;
const _SearchFilters({
required this.withLinks,
required this.withAttachments,
required this.performSearch,
required this.searchController,
required this.isLarge,
});
@override
Widget build(BuildContext context) {
if (isLarge) {
return Row(
children: [
IconButton(
icon: Icon(
Symbols.link,
color: withLinks.value
? Theme.of(context).colorScheme.primary
: Theme.of(context).iconTheme.color,
),
onPressed: () {
withLinks.value = !withLinks.value;
performSearch(searchController.text);
},
tooltip: 'searchLinks'.tr(),
),
IconButton(
icon: Icon(
Symbols.file_copy,
color: withAttachments.value
? Theme.of(context).colorScheme.primary
: Theme.of(context).iconTheme.color,
),
onPressed: () {
withAttachments.value = !withAttachments.value;
performSearch(searchController.text);
},
tooltip: 'searchAttachments'.tr(),
),
],
);
} else {
return Row(
children: [
FilterChip(
avatar: const Icon(Symbols.link, size: 16),
label: const Text('searchLinks').tr(),
selected: withLinks.value,
onSelected: (bool? value) {
withLinks.value = value!;
performSearch(searchController.text);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
const SizedBox(width: 8),
FilterChip(
avatar: const Icon(Symbols.file_copy, size: 16),
label: const Text('searchAttachments').tr(),
selected: withAttachments.value,
onSelected: (bool? value) {
withAttachments.value = value!;
performSearch(searchController.text);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
],
);
}
}
}
class SearchMessagesScreen extends HookConsumerWidget {
final String roomId;
const SearchMessagesScreen({super.key, required this.roomId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchController = useTextEditingController();
final withLinks = useState(false);
final withAttachments = useState(false);
final searchState = useState(SearchState.idle);
final searchResultCount = useState<int?>(null);
final searchResults = useState<AsyncValue<List<dynamic>>>(
const AsyncValue.data([]),
);
// Debounce timer for search optimization
final debounceTimer = useRef<Timer?>(null);
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
// Optimized search function with debouncing
void performSearch(String query) async {
final trimmedQuery = query.trim();
final hasFilters = withLinks.value || withAttachments.value;
if (trimmedQuery.isEmpty && !hasFilters) {
searchState.value = SearchState.idle;
searchResultCount.value = null;
searchResults.value = const AsyncValue.data([]);
return;
}
searchState.value = SearchState.searching;
searchResults.value = const AsyncValue.loading();
// Cancel previous search if still active
debounceTimer.value?.cancel();
// Debounce search to avoid excessive API calls
debounceTimer.value = Timer(const Duration(milliseconds: 300), () async {
try {
final results = await messagesNotifier.getSearchResults(
query.trim(),
withLinks: withLinks.value,
withAttachments: withAttachments.value,
);
searchResults.value = AsyncValue.data(results);
searchState.value = results.isEmpty
? SearchState.noResults
: SearchState.results;
searchResultCount.value = results.length;
} catch (error, stackTrace) {
searchResults.value = AsyncValue.error(error, stackTrace);
searchState.value = SearchState.error;
}
});
}
// Search state is now managed locally in performSearch
useEffect(() {
// Clear search when screen is disposed
return () {
debounceTimer.value?.cancel();
// Note: Don't access ref here as widget may be disposed
// Flashing messages will be cleared by the next screen or jump operation
};
}, []);
// Clear flashing messages when screen initializes (safer than in dispose)
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Clear flashing messages when entering search screen
ref.read(flashingMessagesProvider.notifier).clear();
});
return null;
}, []);
final isLarge = isWideScreen(context);
return AppScaffold(
appBar: AppBar(
title: const Text('searchMessages').tr(),
bottom: searchState.value == SearchState.searching
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(),
)
: null,
),
body: Column(
children: [
// Search input section
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Material(
elevation: 2,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(32),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: isLarge
? Row(
children: [
Expanded(
child: TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'searchMessagesHint'.tr(),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (searchResultCount.value != null &&
searchState.value ==
SearchState.results)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.1),
borderRadius: BorderRadius.circular(
12,
),
),
child: Text(
'${searchResultCount.value}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.primary,
),
),
),
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
performSearch('');
},
),
],
),
),
onChanged: performSearch,
),
),
const SizedBox(width: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _SearchFilters(
withLinks: withLinks,
withAttachments: withAttachments,
performSearch: performSearch,
searchController: searchController,
isLarge: isLarge,
),
),
],
)
: Column(
children: [
TextField(
controller: searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'searchMessagesHint'.tr(),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (searchResultCount.value != null &&
searchState.value == SearchState.results)
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${searchResultCount.value}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.primary,
),
),
),
if (searchController.text.isNotEmpty)
IconButton(
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.clear),
onPressed: () {
searchController.clear();
performSearch('');
},
),
],
),
),
onChanged: performSearch,
),
Padding(
padding: const EdgeInsets.only(
left: 8,
right: 8,
top: 8,
bottom: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_SearchFilters(
withLinks: withLinks,
withAttachments: withAttachments,
performSearch: performSearch,
searchController: searchController,
isLarge: false,
),
],
),
),
],
),
),
),
),
// Search results section
Expanded(
child: searchResults.value.when(
data: (messageList) {
switch (searchState.value) {
case SearchState.idle:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'searchMessagesHint'.tr(),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: Theme.of(context).disabledColor,
),
),
],
),
);
case SearchState.noResults:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'noMessagesFound'.tr(),
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: Theme.of(context).disabledColor,
),
),
const SizedBox(height: 8),
Text(
'tryDifferentKeywords'.tr(),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context).disabledColor,
),
),
],
),
);
case SearchState.results:
return SuperListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16),
reverse: false, // Show newest messages at the top
itemCount: messageList.length,
itemBuilder: (context, index) {
final message = messageList[index];
return MessageListTile(
message: message,
onJump: (messageId) {
// Return the search result and pop back to room detail
context.pop(SearchMessagesResult(messageId));
},
);
},
);
default:
return const SizedBox.shrink();
}
},
loading: () {
if (searchState.value == SearchState.searching) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Searching...'),
],
),
);
}
return const Center(child: CircularProgressIndicator());
},
error: (error, _) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'searchError'.tr(),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () => performSearch(searchController.text),
icon: const Icon(Icons.refresh),
label: const Text('retry').tr(),
),
],
),
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,194 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_pod/call.dart';
import 'package:island/core/widgets/content/markdown.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:pretty_diff_text/pretty_diff_text.dart';
import 'package:styled_widget/styled_widget.dart';
class MessageContent extends StatelessWidget {
final SnChatMessage item;
final String? translatedText;
final bool isSelectable;
const MessageContent({
super.key,
required this.item,
this.translatedText,
this.isSelectable = true,
});
@override
Widget build(BuildContext context) {
if (item.type == 'messages.delete' || item.deletedAt != null) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Symbols.delete,
size: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
),
const Gap(4),
Text(
item.content ?? 'Deleted a message',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 13,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
fontStyle: FontStyle.italic,
),
),
],
);
}
switch (item.type) {
case 'call.start':
case 'call.ended':
return _MessageContentCall(
isEnded: item.type == 'call.ended',
duration: item.meta['duration']?.toDouble(),
);
case 'messages.update':
case 'messages.update.links':
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
item.type == 'messages.update.links'
? Symbols.link
: Symbols.edit,
size: 16,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
),
const Gap(4),
if (item.meta['previous_content'] is String)
Flexible(
child: PrettyDiffText(
oldText: item.meta['previous_content'],
newText:
item.content ??
(item.type == 'messages.update.links'
? 'messageUpdateLinks'.tr()
: 'messageUpdateEdited'.tr()),
defaultTextStyle: Theme.of(
context,
).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
addedTextStyle: TextStyle(
backgroundColor: Theme.of(
context,
).colorScheme.primaryFixedDim.withOpacity(0.4),
),
deletedTextStyle: TextStyle(
decoration: TextDecoration.lineThrough,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.7),
),
),
)
else
Text(
item.content ?? 'Edited a message',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.6),
),
),
],
);
case 'text':
default:
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: MarkdownTextContent(
content: item.content ?? '*${item.type} has no content*',
isSelectable: isSelectable,
linesMargin: EdgeInsets.zero,
),
),
),
if (translatedText?.isNotEmpty ?? false)
...([
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: math.min(
280,
MediaQuery.of(context).size.width * 0.4,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('translated').tr().fontSize(11).opacity(0.75),
const Gap(8),
Flexible(child: Divider()),
],
).padding(vertical: 4),
),
MouseRegion(
cursor: SystemMouseCursors.text,
child: MarkdownTextContent(
content: translatedText!,
isSelectable: isSelectable,
linesMargin: EdgeInsets.zero,
),
),
]),
],
);
}
}
static bool hasContent(SnChatMessage item) {
return item.type != 'text' || (item.content?.isNotEmpty ?? false);
}
}
class _MessageContentCall extends StatelessWidget {
final bool isEnded;
final double? duration;
const _MessageContentCall({required this.isEnded, this.duration});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isEnded ? Symbols.call_end : Symbols.phone_in_talk,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
Gap(4),
Text(
isEnded
? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}'
: 'Call started',
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
],
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/data/message.dart';
import 'package:styled_widget/styled_widget.dart';
class MessageIndicators extends StatelessWidget {
final DateTime? editedAt;
final MessageStatus? status;
final bool isCurrentUser;
final Color textColor;
final EdgeInsets padding;
const MessageIndicators({
super.key,
this.editedAt,
this.status,
required this.isCurrentUser,
required this.textColor,
this.padding = const EdgeInsets.only(left: 6),
});
@override
Widget build(BuildContext context) {
final children = <Widget>[];
if (editedAt != null) {
children.add(
Text(
'edited'.tr().toLowerCase(),
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
),
);
}
if (isCurrentUser && status != null && status != MessageStatus.sent) {
children.add(
_buildStatusIcon(
context,
status!,
textColor.withOpacity(0.7),
).padding(bottom: 2),
);
}
if (children.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: padding,
child: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: children,
),
);
}
Widget _buildStatusIcon(
BuildContext context,
MessageStatus status,
Color textColor,
) {
switch (status) {
case MessageStatus.pending:
return SizedBox(
width: 10,
height: 10,
child: CircularProgressIndicator(
padding: EdgeInsets.zero,
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(textColor),
),
).padding(bottom: 2);
case MessageStatus.sent:
// Sent status is hidden
return const SizedBox.shrink();
case MessageStatus.failed:
return Consumer(
builder:
(context, ref, _) => GestureDetector(
onTap: () {
// This would need to be passed in or accessed differently
// For now, just show the error icon
},
child: const Icon(
Icons.error_outline,
size: 12,
color: Colors.red,
),
),
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_widgets/message_item.dart';
import 'package:island/data/message.dart';
final animatedMessagesProvider = NotifierProvider.autoDispose(
AnimatedMessagesNotifier.new,
);
class AnimatedMessagesNotifier extends Notifier<Set<String>> {
@override
Set<String> build() {
return {};
}
void addMessage(String messageId) {
state = {...state, messageId};
}
}
class MessageItemWrapper extends HookConsumerWidget {
final LocalChatMessage message;
final int index;
final bool isLastInGroup;
final bool isSelectionMode;
final Set<String> selectedMessages;
final AsyncValue<SnChatMember?> chatIdentity;
final VoidCallback toggleSelectionMode;
final Function(String) toggleMessageSelection;
final Function(String, LocalChatMessage) onMessageAction;
final Function(String) onJump;
final Map<String, Map<int, double?>> attachmentProgress;
final bool disableAnimation;
final DateTime roomOpenTime;
const MessageItemWrapper({
super.key,
required this.message,
required this.index,
required this.isLastInGroup,
required this.isSelectionMode,
required this.selectedMessages,
required this.chatIdentity,
required this.toggleSelectionMode,
required this.toggleMessageSelection,
required this.onMessageAction,
required this.onJump,
required this.attachmentProgress,
required this.disableAnimation,
required this.roomOpenTime,
});
Widget _buildContent(BuildContext context, SnChatMember? identity) {
final isSelected = selectedMessages.contains(message.id);
final isCurrentUser = identity?.id == message.senderId;
return GestureDetector(
onLongPress: () {
if (!isSelectionMode) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode) {
toggleMessageSelection(message.id);
}
},
child: Container(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3)
: null,
child: Stack(
children: [
MessageItem(
// If animation is disabled, we might want to pass a key to maintain state?
// But here we are inside the wrapper.
key: ValueKey('item-${message.id}'),
message: message,
isCurrentUser: isCurrentUser,
onAction: isSelectionMode
? null
: (action) => onMessageAction(action, message),
onJump: onJump,
progress: attachmentProgress[message.id],
showAvatar: isLastInGroup,
isSelectionMode: isSelectionMode,
isSelected: isSelected,
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
if (!isSelectionMode) toggleSelectionMode();
},
),
if (isSelected)
Positioned(
top: 8,
right: 8,
child: Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
size: 12,
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
),
),
);
}
Widget _buildLoading() {
return MessageItem(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// Animation logic
final animatedMessages = ref.watch(animatedMessagesProvider);
final isNewMessage = message.createdAt.isAfter(roomOpenTime);
final hasAnimated = animatedMessages.contains(message.id);
// Only animate if:
// 1. Animation is enabled
// 2. Message is new (created after room open)
// 3. Has not animated yet
final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated;
final child = chatIdentity.when(
skipError: true,
data: (identity) => _buildContent(context, identity),
loading: () => _buildLoading(),
error: (_, _) => const SizedBox.shrink(),
);
final controller = useAnimationController(
duration: Duration(milliseconds: 400 + (index % 5) * 50),
);
final hasStarted = useState(false);
useEffect(() {
if (shouldAnimate && !hasStarted.value) {
hasStarted.value = true;
controller.forward().then((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(animatedMessagesProvider.notifier).addMessage(message.id);
});
});
}
return null;
}, [shouldAnimate]);
if (!shouldAnimate) {
return child;
}
final curvedAnimation = useMemoized(
() => CurvedAnimation(parent: controller, curve: Curves.easeOutQuart),
[controller],
);
final sizeAnimation = useMemoized(
() => Tween<double>(begin: 0.0, end: 1.0).animate(curvedAnimation),
[curvedAnimation],
);
final slideAnimation = useMemoized(
() => Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(curvedAnimation),
[curvedAnimation],
);
final fadeAnimation = useMemoized(
() => Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller,
curve: const Interval(0.1, 1.0, curve: Curves.easeOut),
),
),
[controller],
);
return AnimatedBuilder(
animation: controller,
builder: (context, child) => FadeTransition(
opacity: fadeAnimation,
child: SizeTransition(
axis: Axis.vertical,
sizeFactor: sizeAnimation,
child: SlideTransition(position: slideAnimation, child: child),
),
),
child: child,
);
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:island/chat/chat_widgets/message_content.dart';
import 'package:island/chat/chat_widgets/message_sender_info.dart';
import 'package:island/data/message.dart';
import 'package:island/posts/posts_models/embed.dart';
import 'package:island/core/utils/mapping.dart';
import 'package:island/core/widgets/content/cloud_file_collection.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/embed/link.dart';
class MessageListTile extends StatelessWidget {
final LocalChatMessage message;
final Function(String messageId) onJump;
const MessageListTile({
super.key,
required this.message,
required this.onJump,
});
@override
Widget build(BuildContext context) {
final remoteMessage = message.toRemoteMessage();
final sender = remoteMessage.sender;
return ListTile(
isThreeLine: true,
leading: CircleAvatar(
radius: 20,
backgroundColor: Colors.transparent,
child: ProfilePictureWidget(
file: sender.account.profile.picture,
radius: 20,
),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MessageSenderInfo(
sender: sender,
createdAt: message.createdAt,
textColor: Theme.of(context).colorScheme.onSurfaceVariant,
showAvatar: false,
isCompact: true,
),
const SizedBox(height: 4),
MessageContent(item: remoteMessage, isSelectable: false),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (remoteMessage.attachments.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
return CloudFileList(
files: remoteMessage.attachments,
maxWidth: constraints.maxWidth,
padding: EdgeInsets.symmetric(vertical: 4),
);
},
),
if (remoteMessage.meta['embeds'] != null)
...((remoteMessage.meta['embeds'] as List<dynamic>)
.map((embed) => convertMapKeysToSnakeCase(embed))
.where((embed) => embed['type'] == 'link')
.map((embed) => SnScrappedLink.fromJson(embed))
.map(
(link) => LayoutBuilder(
builder: (context, constraints) {
return EmbedLinkWidget(
link: link,
maxWidth: math.min(constraints.maxWidth, 480),
margin: const EdgeInsets.symmetric(vertical: 4),
);
},
),
)
.toList()),
],
),
onTap: () => onJump(message.id),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
dense: true,
);
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:gap/gap.dart';
import 'package:island/accounts/accounts_widgets/account/account_name.dart';
import 'package:island/accounts/accounts_widgets/account/account_pfc.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
class MessageSenderInfo extends StatelessWidget {
final SnChatMember sender;
final DateTime createdAt;
final Color textColor;
final bool showAvatar;
final bool isCompact;
const MessageSenderInfo({
super.key,
required this.sender,
required this.createdAt,
required this.textColor,
this.showAvatar = true,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
final timestamp = DateTime.now().difference(createdAt).inDays > 365
? DateFormat('yyyy/MM/dd HH:mm').format(createdAt.toLocal())
: DateTime.now().difference(createdAt).inDays > 0
? DateFormat('MM/dd HH:mm').format(createdAt.toLocal())
: DateFormat('HH:mm').format(createdAt.toLocal());
if (isCompact) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
if (showAvatar)
AccountPfcRegion(
uname: sender.account.name,
child: ProfilePictureWidget(
file: sender.account.profile.picture,
radius: 14,
),
),
if (showAvatar) const Gap(4),
AccountName(
account: sender.account,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: textColor,
fontWeight: FontWeight.w500,
),
),
const Gap(6),
Text(
timestamp,
style: TextStyle(fontSize: 10, color: textColor.withOpacity(0.7)),
),
],
);
}
if (showAvatar) {
return Row(
spacing: 8,
children: [
AccountPfcRegion(
uname: sender.account.name,
child: ProfilePictureWidget(
file: sender.account.profile.picture,
radius: 14,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountName(
account: sender.account,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: textColor,
fontWeight: FontWeight.w500,
),
),
Text(
timestamp,
style: TextStyle(
fontSize: 10,
color: textColor.withOpacity(0.7),
),
),
],
),
),
],
);
}
return Row(
spacing: 8,
mainAxisSize: MainAxisSize.min,
children: [
if (showAvatar)
AccountPfcRegion(
uname: sender.account.name,
child: ProfilePictureWidget(
file: sender.account.profile.picture,
radius: 16,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 2,
children: [
Text(timestamp, style: TextStyle(fontSize: 10, color: textColor)),
AccountName(
account: sender.account,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
);
}
}

View File

@@ -0,0 +1,216 @@
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
import "package:go_router/go_router.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:gap/gap.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/chat/chat_widgets/message_item.dart";
import "package:island/chat/messages_notifier.dart";
import "package:island/data/message.dart";
import "package:island/chat/chat_models/chat.dart";
import "package:island/chat/chat_pod/chat_room.dart";
import "package:island/core/network.dart";
import "package:island/core/services/responsive.dart";
import "package:island/shared/widgets/alert.dart";
import "package:island/shared/widgets/app_scaffold.dart";
import "package:island/drive/drive_widgets/cloud_files.dart";
import "package:island/shared/widgets/response.dart";
import "package:material_symbols_icons/material_symbols_icons.dart";
import "package:styled_widget/styled_widget.dart";
import "package:super_sliver_list/super_sliver_list.dart";
import "package:material_symbols_icons/symbols.dart";
class PublicRoomPreview extends HookConsumerWidget {
final String id;
final SnChatRoom room;
const PublicRoomPreview({super.key, required this.id, required this.room});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(messagesProvider(id));
final messagesNotifier = ref.read(messagesProvider(id).notifier);
final scrollController = useScrollController();
final listController = useMemoized(() => ListController(), []);
var isLoading = false;
// Add scroll listener for pagination
useEffect(() {
void onScroll() {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200) {
if (isLoading) return;
isLoading = true;
messagesNotifier.loadMore().then((_) => isLoading = false);
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController]);
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
SuperListView.builder(
listController: listController,
padding: EdgeInsets.symmetric(vertical: 16),
controller: scrollController,
reverse: true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
final valueKey = key as ValueKey;
final messageId = valueKey.value as String;
return messageList.indexWhere((m) => m.id == messageId);
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage = index < messageList.length - 1
? messageList[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
return MessageItem(
message: message,
isCurrentUser: false, // User is not a member, so not current user
onAction: null, // No actions allowed in preview mode
onJump: (_) {}, // No jump functionality in preview
progress: null,
showAvatar: isLastInGroup,
);
},
);
final compactHeader = isWideScreen(context);
Widget comfortHeaderWidget() => Column(
spacing: 4,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 26,
width: 26,
child: (room.type == 1 && room.picture == null)
? SplitAvatarWidget(
files: room.members!
.map((e) => e.account.profile.picture)
.toList(),
)
: room.picture != null
? ProfilePictureWidget(
file: room.picture,
fallbackIcon: Symbols.chat,
)
: CircleAvatar(
child: Text(
room.name![0].toUpperCase(),
style: const TextStyle(fontSize: 12),
),
),
),
Text(
(room.type == 1 && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ')
: room.name!,
).fontSize(15),
],
);
Widget compactHeaderWidget() => Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 26,
width: 26,
child: (room.type == 1 && room.picture == null)
? SplitAvatarWidget(
files: room.members!
.map((e) => e.account.profile.picture)
.toList(),
)
: room.picture != null
? ProfilePictureWidget(
file: room.picture,
fallbackIcon: Symbols.chat,
)
: CircleAvatar(
child: Text(
room.name![0].toUpperCase(),
style: const TextStyle(fontSize: 12),
),
),
),
Text(
(room.type == 1 && room.name == null)
? room.members!.map((e) => e.account.nick).join(', ')
: room.name!,
).fontSize(19),
],
);
return AppScaffold(
appBar: AppBar(
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
automaticallyImplyLeading: false,
toolbarHeight: compactHeader ? null : 64,
title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(),
actions: [
IconButton(
icon: const Icon(Icons.more_vert),
onPressed: () {
context.pushNamed('chatDetail', pathParameters: {'id': id});
},
),
const Gap(8),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: messages.when(
data: (messageList) => messageList.isEmpty
? Center(child: Text('No messages yet'.tr()))
: chatMessageListWidget(messageList),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () => messagesNotifier.loadInitial(),
),
),
),
// Join button at the bottom for public rooms
Container(
padding: const EdgeInsets.all(16),
child: FilledButton.tonalIcon(
onPressed: () async {
try {
showLoadingModal(context);
final apiClient = ref.read(apiClientProvider);
await apiClient.post('/messager/chat/${room.id}/members/me');
ref.invalidate(chatRoomIdentityProvider(id));
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
label: Text('chatJoin').tr(),
icon: const Icon(Icons.add),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
List<SnChatMember> getValidMembers(List<SnChatMember> members, String? userId) {
return members.where((member) => member.accountId != userId).toList();
}
class RoomAppBar extends ConsumerWidget {
final SnChatRoom room;
final int onlineCount;
final bool compact;
const RoomAppBar({
super.key,
required this.room,
required this.onlineCount,
required this.compact,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(userInfoProvider);
final validMembers = getValidMembers(
room.members ?? [],
userInfo.value?.id,
);
if (compact) {
return Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,
children: [
_OnlineCountBadge(
onlineCount: onlineCount,
child: _RoomAvatar(
room: room,
validMembers: validMembers,
size: 28,
),
),
Text(
(room.type == 1 && room.name == null)
? validMembers.map((e) => e.account.nick).join(', ')
: room.name!,
).fontSize(19),
],
);
}
return Column(
spacing: 4,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_OnlineCountBadge(
onlineCount: onlineCount,
child: _RoomAvatar(room: room, validMembers: validMembers, size: 26),
),
Text(
(room.type == 1 && room.name == null)
? validMembers.map((e) => e.account.nick).join(', ')
: room.name!,
).fontSize(15),
],
);
}
}
class _OnlineCountBadge extends StatelessWidget {
final int onlineCount;
final Widget child;
const _OnlineCountBadge({required this.onlineCount, required this.child});
@override
Widget build(BuildContext context) {
return Badge(
isLabelVisible: onlineCount > 1,
label: Text('$onlineCount'),
textStyle: GoogleFonts.robotoMono(fontSize: 10),
textColor: Colors.white,
backgroundColor: onlineCount > 1 ? Colors.green : Colors.grey,
offset: const Offset(6, 14),
child: child,
);
}
}
class _RoomAvatar extends StatelessWidget {
final SnChatRoom room;
final List<SnChatMember> validMembers;
final double size;
const _RoomAvatar({
required this.room,
required this.validMembers,
required this.size,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: size,
width: size,
child: (room.type == 1 && room.picture == null)
? SplitAvatarWidget(
files: validMembers
.map((e) => e.account.profile.picture)
.toList(),
)
: room.picture != null
? ProfilePictureWidget(file: room.picture, fallbackIcon: Symbols.chat)
: CircleAvatar(
child: Text(
room.name![0].toUpperCase(),
style: const TextStyle(fontSize: 12),
),
),
);
}
}

View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_widgets/message_item_wrapper.dart';
import 'package:island/data/message.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/core/config.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class RoomMessageList extends HookConsumerWidget {
final List<LocalChatMessage> messages;
final AsyncValue<SnChatRoom?> roomAsync;
final AsyncValue<SnChatMember?> chatIdentity;
final ScrollController scrollController;
final ListController listController;
final bool isSelectionMode;
final Set<String> selectedMessages;
final VoidCallback toggleSelectionMode;
final void Function(String) toggleMessageSelection;
final void Function(String action, LocalChatMessage message) onMessageAction;
final void Function(String messageId) onJump;
final Map<String, Map<int, double?>> attachmentProgress;
final bool disableAnimation;
final DateTime roomOpenTime;
final double inputHeight;
final double? previousInputHeight;
const RoomMessageList({
super.key,
required this.messages,
required this.roomAsync,
required this.chatIdentity,
required this.scrollController,
required this.listController,
required this.isSelectionMode,
required this.selectedMessages,
required this.toggleSelectionMode,
required this.toggleMessageSelection,
required this.onMessageAction,
required this.onJump,
required this.attachmentProgress,
required this.disableAnimation,
required this.roomOpenTime,
required this.inputHeight,
this.previousInputHeight,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsProvider);
const messageKeyPrefix = 'message-';
final bottomPadding =
inputHeight + MediaQuery.of(context).padding.bottom + 8;
final listWidget =
previousInputHeight != null && previousInputHeight != inputHeight
? TweenAnimationBuilder<double>(
tween: Tween<double>(begin: previousInputHeight, end: inputHeight),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder: (context, height, child) => SuperListView.builder(
listController: listController,
controller: scrollController,
reverse: true,
padding: EdgeInsets.only(
top: 8,
bottom: height + MediaQuery.of(context).padding.bottom + 8,
),
itemCount: messages.length,
findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(messageKeyPrefix.length);
final index = messages.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
return index >= 0 ? index : null;
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messages[index];
final nextMessage = index < messages.length - 1
? messages[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
final key = Key(
'$messageKeyPrefix${message.nonce ?? message.id}',
);
return MessageItemWrapper(
key: key,
message: message,
index: index,
isLastInGroup: isLastInGroup,
isSelectionMode: isSelectionMode,
selectedMessages: selectedMessages,
chatIdentity: chatIdentity,
toggleSelectionMode: toggleSelectionMode,
toggleMessageSelection: toggleMessageSelection,
onMessageAction: onMessageAction,
onJump: onJump,
attachmentProgress: attachmentProgress,
disableAnimation: settings.disableAnimation,
roomOpenTime: roomOpenTime,
);
},
),
)
: SuperListView.builder(
listController: listController,
controller: scrollController,
reverse: true,
padding: EdgeInsets.only(top: 8, bottom: bottomPadding),
itemCount: messages.length,
findChildIndexCallback: (key) {
if (key is! ValueKey<String>) return null;
final messageId = key.value.substring(messageKeyPrefix.length);
final index = messages.indexWhere(
(m) => (m.nonce ?? m.id) == messageId,
);
return index >= 0 ? index : null;
},
extentEstimation: (_, _) => 40,
itemBuilder: (context, index) {
final message = messages[index];
final nextMessage = index < messages.length - 1
? messages[index + 1]
: null;
final isLastInGroup =
nextMessage == null ||
nextMessage.senderId != message.senderId ||
nextMessage.createdAt
.difference(message.createdAt)
.inMinutes
.abs() >
3;
final key = Key(
'$messageKeyPrefix${message.nonce ?? message.id}',
);
return MessageItemWrapper(
key: key,
message: message,
index: index,
isLastInGroup: isLastInGroup,
isSelectionMode: isSelectionMode,
selectedMessages: selectedMessages,
chatIdentity: chatIdentity,
toggleSelectionMode: toggleSelectionMode,
toggleMessageSelection: toggleMessageSelection,
onMessageAction: onMessageAction,
onJump: onJump,
attachmentProgress: attachmentProgress,
disableAnimation: settings.disableAnimation,
roomOpenTime: roomOpenTime,
);
},
);
return listWidget;
}
}

View File

@@ -0,0 +1,103 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/chat/chat_widgets/call_overlay.dart';
import 'package:styled_widget/styled_widget.dart';
class RoomOverlays extends ConsumerWidget {
final AsyncValue<SnChatRoom?> roomAsync;
final bool isSyncing;
final bool showGradient;
final double bottomGradientOpacity;
final double inputHeight;
const RoomOverlays({
super.key,
required this.roomAsync,
required this.isSyncing,
required this.showGradient,
required this.bottomGradientOpacity,
required this.inputHeight,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: roomAsync.when(
data: (data) => data != null
? CallOverlayBar(room: data).padding(horizontal: 8, top: 12)
: const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
),
if (isSyncing)
Positioned(
top: 8,
right: 16,
child: Container(
padding: EdgeInsets.fromLTRB(
8,
8,
8,
8 + MediaQuery.of(context).padding.bottom,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).scaffoldBackgroundColor.withOpacity(0.8),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'Syncing...',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
if (showGradient)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Opacity(
opacity: bottomGradientOpacity,
child: Container(
height: math.min(MediaQuery.of(context).size.height * 0.1, 128),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.8),
Theme.of(
context,
).colorScheme.surfaceContainer.withOpacity(0.0),
],
),
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:material_symbols_icons/symbols.dart';
class RoomSelectionMode extends StatelessWidget {
final bool visible;
final int selectedCount;
final VoidCallback onClose;
final VoidCallback onAIThink;
const RoomSelectionMode({
super.key,
required this.visible,
required this.selectedCount,
required this.onClose,
required this.onAIThink,
});
@override
Widget build(BuildContext context) {
if (!visible) return const SizedBox.shrink();
return Container(
color: Theme.of(context).colorScheme.surface,
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: MediaQuery.of(context).padding.bottom + 8,
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: onClose,
tooltip: 'Cancel selection',
),
const SizedBox(width: 8),
Text(
'$selectedCount selected',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
if (selectedCount > 0)
FilledButton.icon(
onPressed: onAIThink,
icon: const Icon(Symbols.smart_toy),
label: const Text('AI Think'),
),
],
),
);
}
}

View File

@@ -0,0 +1,135 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/chat/chat_widgets/chat_link_attachments.dart';
import 'package:island/drive/drive_models/file.dart';
class RoomFilePicker {
final List<UniversalFile> attachments;
final void Function(List<UniversalFile>) updateAttachments;
final Future<void> Function() pickPhotos;
final Future<void> Function() pickVideos;
final Future<void> Function() pickAudio;
final Future<void> Function() pickFiles;
final Future<void> Function() linkAttachment;
RoomFilePicker({
required this.attachments,
required this.updateAttachments,
required this.pickPhotos,
required this.pickVideos,
required this.pickAudio,
required this.pickFiles,
required this.linkAttachment,
});
}
RoomFilePicker useRoomFilePicker(
BuildContext context,
List<UniversalFile> currentAttachments,
Function(List<UniversalFile>) onAttachmentsChanged,
) {
final attachments = useState<List<UniversalFile>>(currentAttachments);
Future<void> pickPhotos() async {
final picker = ImagePicker();
final results = await picker.pickMultiImage();
if (results.isEmpty) return;
attachments.value = [
...attachments.value,
...results.map(
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
),
];
onAttachmentsChanged(attachments.value);
}
Future<void> pickVideos() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.video,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.video),
),
];
onAttachmentsChanged(attachments.value);
}
Future<void> pickAudio() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.audio,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.audio),
),
];
onAttachmentsChanged(attachments.value);
}
Future<void> pickFiles() async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.file),
),
];
onAttachmentsChanged(attachments.value);
}
Future<void> linkAttachment() async {
final cloudFile = await showModalBottomSheet<SnCloudFile?>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const ChatLinkAttachment(),
);
if (cloudFile == null) return;
attachments.value = [
...attachments.value,
UniversalFile(
data: cloudFile,
type: switch (cloudFile.mimeType?.split('/').firstOrNull) {
'image' => UniversalFileType.image,
'video' => UniversalFileType.video,
'audio' => UniversalFileType.audio,
_ => UniversalFileType.file,
},
isLink: true,
),
];
onAttachmentsChanged(attachments.value);
}
void updateAttachments(List<UniversalFile> newAttachments) {
attachments.value = newAttachments;
onAttachmentsChanged(attachments.value);
}
return RoomFilePicker(
attachments: attachments.value,
updateAttachments: updateAttachments,
pickPhotos: pickPhotos,
pickVideos: pickVideos,
pickAudio: pickAudio,
pickFiles: pickFiles,
linkAttachment: linkAttachment,
);
}

View File

@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/chat/chat_widgets/message_item.dart';
import 'package:island/chat/messages_notifier.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/chat/chat_models/chat.dart';
import 'package:island/posts/posts_models/poll.dart';
import 'package:island/wallet/wallet_models/wallet.dart';
import 'package:island/chat/chat_pod/chat_subscribe.dart';
import 'package:island/data/message.dart';
import 'package:pasteboard/pasteboard.dart';
class RoomInputManager {
final TextEditingController messageController;
final List<UniversalFile> attachments;
final Map<String, Map<int, double?>> attachmentProgress;
final SnChatMessage? messageEditingTo;
final SnChatMessage? messageReplyingTo;
final SnChatMessage? messageForwardingTo;
final SnPoll? selectedPoll;
final SnWalletFund? selectedFund;
final void Function(List<UniversalFile>) updateAttachments;
final void Function(String, double?) updateAttachmentProgress;
final void Function(SnChatMessage?) setEditingTo;
final void Function(SnChatMessage?) setReplyingTo;
final void Function(SnChatMessage?) setForwardingTo;
final void Function(SnPoll?) setPoll;
final void Function(SnWalletFund?) setFund;
final void Function() clear;
final void Function() clearAttachmentsOnly;
final Future<void> Function() handlePaste;
final void Function(WidgetRef ref) sendMessage;
final void Function(String action, LocalChatMessage message) onMessageAction;
RoomInputManager({
required this.messageController,
required this.attachments,
required this.attachmentProgress,
this.messageEditingTo,
this.messageReplyingTo,
this.messageForwardingTo,
this.selectedPoll,
this.selectedFund,
required this.updateAttachments,
required this.updateAttachmentProgress,
required this.setEditingTo,
required this.setReplyingTo,
required this.setForwardingTo,
required this.setPoll,
required this.setFund,
required this.clear,
required this.clearAttachmentsOnly,
required this.handlePaste,
required this.sendMessage,
required this.onMessageAction,
});
}
RoomInputManager useRoomInputManager(WidgetRef ref, String roomId) {
final messageController = useTextEditingController();
final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
final messageEditingTo = useState<SnChatMessage?>(null);
final messageReplyingTo = useState<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null);
final selectedPoll = useState<SnPoll?>(null);
final selectedFund = useState<SnWalletFund?>(null);
final chatSubscribeNotifier = ref.read(
chatSubscribeProvider(roomId).notifier,
);
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
void updateAttachments(List<UniversalFile> newAttachments) {
attachments.value = newAttachments;
}
void updateAttachmentProgress(String messageId, double? progress) {
attachmentProgress.value = {
...attachmentProgress.value,
messageId: {0: progress},
};
}
void setEditingTo(SnChatMessage? message) {
messageEditingTo.value = message;
if (message != null) {
messageController.text = message.content ?? '';
attachments.value = message.attachments
.map((e) => UniversalFile.fromAttachment(e))
.toList();
}
}
void setReplyingTo(SnChatMessage? message) {
messageReplyingTo.value = message;
}
void setForwardingTo(SnChatMessage? message) {
messageForwardingTo.value = message;
}
void setPoll(SnPoll? poll) {
selectedPoll.value = poll;
}
void setFund(SnWalletFund? fund) {
selectedFund.value = fund;
}
void clear() {
messageController.clear();
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
attachments.value = [];
}
void clearAttachmentsOnly() {
messageController.clear();
attachments.value = [];
}
void onTextChange() {
if (messageController.text.isNotEmpty) {
chatSubscribeNotifier.sendTypingStatus();
}
}
useEffect(() {
messageController.addListener(onTextChange);
return () => messageController.removeListener(onTextChange);
}, [messageController]);
Future<void> handlePaste() async {
final image = await Pasteboard.image;
if (image != null) {
final newAttachments = [
...attachments.value,
UniversalFile(
displayName: 'image.jpeg',
data: XFile.fromData(
image,
mimeType: "image/jpeg",
name: 'image.jpeg',
),
type: UniversalFileType.image,
),
];
attachments.value = newAttachments;
}
final textData = await Clipboard.getData(Clipboard.kTextPlain);
if (textData != null && textData.text != null) {
final text = messageController.text;
final selection = messageController.selection;
final start = selection.start >= 0 ? selection.start : text.length;
final end = selection.end >= 0 ? selection.end : text.length;
final newText = text.replaceRange(start, end, textData.text!);
messageController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: start + textData.text!.length,
),
);
}
}
void onMessageAction(String action, LocalChatMessage message) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(message.id);
case MessageItemAction.edit:
setEditingTo(message.toRemoteMessage());
case MessageItemAction.forward:
setForwardingTo(message.toRemoteMessage());
case MessageItemAction.reply:
setReplyingTo(message.toRemoteMessage());
case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id);
}
}
void sendMessage(WidgetRef ref) {
if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty ||
selectedPoll.value != null ||
selectedFund.value != null) {
messagesNotifier.sendMessage(
ref,
messageController.text.trim(),
attachments.value,
poll: selectedPoll.value,
fund: selectedFund.value,
editingTo: messageEditingTo.value,
forwardingTo: messageForwardingTo.value,
replyingTo: messageReplyingTo.value,
onProgress: (messageId, progress) {
attachmentProgress.value = {
...attachmentProgress.value,
messageId: progress,
};
},
);
clear();
}
}
return RoomInputManager(
messageController: messageController,
attachments: attachments.value,
attachmentProgress: attachmentProgress.value,
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
selectedPoll: selectedPoll.value,
selectedFund: selectedFund.value,
updateAttachments: updateAttachments,
updateAttachmentProgress: updateAttachmentProgress,
setEditingTo: setEditingTo,
setReplyingTo: setReplyingTo,
setForwardingTo: setForwardingTo,
setPoll: setPoll,
setFund: setFund,
clear: clear,
clearAttachmentsOnly: clearAttachmentsOnly,
handlePaste: handlePaste,
sendMessage: sendMessage,
onMessageAction: onMessageAction,
);
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/chat/chat_pod/chat_room.dart';
import 'package:island/chat/messages_notifier.dart';
import 'package:island/data/message.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class RoomScrollManager {
final ScrollController scrollController;
final ListController listController;
final ValueNotifier<double> bottomGradientOpacity;
bool isScrollingToMessage;
final void Function({
required String messageId,
required List<LocalChatMessage> messageList,
})
scrollToMessage;
RoomScrollManager({
required this.scrollController,
required this.listController,
required this.bottomGradientOpacity,
required this.scrollToMessage,
this.isScrollingToMessage = false,
});
}
RoomScrollManager useRoomScrollManager(
WidgetRef ref,
String roomId,
Future<int> Function(String) jumpToMessage,
AsyncValue<List<LocalChatMessage>> messagesAsync,
) {
final scrollController = useScrollController();
final listController = useMemoized(() => ListController(), []);
final bottomGradientOpacity = useState(ValueNotifier<double>(0.0));
var isLoading = false;
var isScrollingToMessage = false;
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
final flashingMessagesNotifier = ref.read(flashingMessagesProvider.notifier);
void performScrollAnimation({required int index, required String messageId}) {
flashingMessagesNotifier.update((set) => set.union({messageId}));
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
listController.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (estimatedDistance) => Duration(
milliseconds: (estimatedDistance * 0.5).clamp(200, 800).toInt(),
),
curve: (estimatedDistance) => Curves.easeOutCubic,
);
Future.delayed(const Duration(milliseconds: 800), () {
isScrollingToMessage = false;
});
} catch (e) {
isScrollingToMessage = false;
}
});
});
}
void scrollToMessageWrapper({
required String messageId,
required List<LocalChatMessage> messageList,
}) {
if (isScrollingToMessage) return;
isScrollingToMessage = true;
final messageIndex = messageList.indexWhere((m) => m.id == messageId);
if (messageIndex == -1) {
jumpToMessage(messageId).then((index) {
if (index != -1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
performScrollAnimation(index: index, messageId: messageId);
});
} else {
isScrollingToMessage = false;
}
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
performScrollAnimation(index: messageIndex, messageId: messageId);
});
}
}
useEffect(() {
void onScroll() {
messagesAsync.when(
data: (messageList) {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200) {
if (!isLoading) {
isLoading = true;
messagesNotifier.loadMore().then((_) => isLoading = false);
}
}
final pixels = scrollController.position.pixels;
bottomGradientOpacity.value.value = (pixels / 500.0).clamp(0.0, 1.0);
},
loading: () {},
error: (_, _) {},
);
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController, messagesAsync]);
return RoomScrollManager(
scrollController: scrollController,
listController: listController,
bottomGradientOpacity: bottomGradientOpacity.value,
scrollToMessage: scrollToMessageWrapper,
isScrollingToMessage: isScrollingToMessage,
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'messages_notifier.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(MessagesNotifier)
final messagesProvider = MessagesNotifierFamily._();
final class MessagesNotifierProvider
extends $AsyncNotifierProvider<MessagesNotifier, List<LocalChatMessage>> {
MessagesNotifierProvider._({
required MessagesNotifierFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'messagesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$messagesNotifierHash();
@override
String toString() {
return r'messagesProvider'
''
'($argument)';
}
@$internal
@override
MessagesNotifier create() => MessagesNotifier();
@override
bool operator ==(Object other) {
return other is MessagesNotifierProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$messagesNotifierHash() => r'622fed0908eb04381a971e36540c516743246dff';
final class MessagesNotifierFamily extends $Family
with
$ClassFamilyOverride<
MessagesNotifier,
AsyncValue<List<LocalChatMessage>>,
List<LocalChatMessage>,
FutureOr<List<LocalChatMessage>>,
String
> {
MessagesNotifierFamily._()
: super(
retry: null,
name: r'messagesProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
MessagesNotifierProvider call(String roomId) =>
MessagesNotifierProvider._(argument: roomId, from: this);
@override
String toString() => r'messagesProvider';
}
abstract class _$MessagesNotifier
extends $AsyncNotifier<List<LocalChatMessage>> {
late final _$args = ref.$arg as String;
String get roomId => _$args;
FutureOr<List<LocalChatMessage>> build(String roomId);
@$mustCallSuper
@override
void runBuild() {
final ref =
this.ref
as $Ref<AsyncValue<List<LocalChatMessage>>, List<LocalChatMessage>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<List<LocalChatMessage>>,
List<LocalChatMessage>
>,
AsyncValue<List<LocalChatMessage>>,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}