feat(chat): add 1:1 and family group chat module for legacy dashboard

- Module structure mirrors location pattern (core/data, core/domain, core/providers, features)
- Supports text, emoji, image and audio messages over POST /chat-messages multipart
- Optimistic UI with status reconciliation via 4s polling and circuit breaker after 3 errors
- Offline queue persisted in SharedPreferences, drained on conversation re-open
- WhatsApp-style audio recorder with long-press, slide-to-cancel, haptics and animated overlay
- Image picker (camera/gallery) with on-device 1024px JPEG compression
- Single-audio playback coordinator across bubbles
- Family group fan-out: N parallel POSTs sharing chatId, members derived from delegationId
- Reuses LegacyOptionCard extracted from videocall idle screen
- Tracking events legacy_chat_opened, message_sent, image_sent, audio_sent, permission_denied (no PII)
- WebSocket ChatMessageEvent parser added for future backend support
- Push command CHAT_MESSAGE handled in notifications_init for deep-linking
- 15 unit tests covering id resolver, file url builder and repository

Pending backend coordination: GET /chat-messages 500 (parseQueryParams), push routing heuristic, file size/mime limits.
This commit is contained in:
2026-05-05 23:32:54 -05:00
parent 62b38acab4
commit 54b81818ec
91 changed files with 7234 additions and 78 deletions

View File

@@ -10,6 +10,8 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />

View File

@@ -165,7 +165,17 @@ void _handleNotificationNavigation(Map<String, dynamic> data) {
appRouter.routerDelegate.currentConfiguration.uri.path;
if (!currentLocation.startsWith(AppRoutes.legacyDashboard)) return;
final command = data['command'] as String?;
Map<String, dynamic> resolved = data;
final pushData = data['pushData'];
if (pushData is String && pushData.isNotEmpty) {
try {
resolved = jsonDecode(pushData) as Map<String, dynamic>;
} catch (e) {
debugPrint('[Notification] failed to parse pushData: $e');
}
}
final command = resolved['command'] as String? ?? data['command'] as String?;
switch (command) {
case 'ALERT':
@@ -174,6 +184,13 @@ void _handleNotificationNavigation(Map<String, dynamic> data) {
// and have NotificationsScreen pre-select `notificationsFilterProvider`.
// Until then, land on the category picker ("all").
appRouter.go(AppRoutes.deviceNotifications);
case 'CHAT_MESSAGE':
final chatId = resolved['chatId'] as String?;
if (chatId != null && chatId.isNotEmpty) {
appRouter.go(AppRoutes.legacyChatConversationFor(chatId));
} else {
appRouter.go(AppRoutes.legacyChat);
}
default:
debugPrint('[Notification] unhandled command: $command');
}

View File

@@ -7,6 +7,7 @@ import 'package:dashboard_shell/dashboard_builder.dart';
import 'package:device_management/device_management.dart';
import 'package:control_panel/control_panel.dart';
import 'package:legacy_dashboard_shell/legacy_dashboard_builder.dart';
import 'package:chat/chat.dart';
import 'package:location/location.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
@@ -249,19 +250,20 @@ void configureAppRouter() {
),
],
),
// TODO: Añadir branch para Chat (tab 4)
StatefulShellBranch(
navigatorKey: _legacyChatNavKey,
routes: [
GoRoute(
path: '${AppRoutes.legacyDashboard}/chat',
path: AppRoutes.legacyChat,
name: 'legacy_chat',
pageBuilder: (context, state) => MaterialPage<void>(
key: state.pageKey,
child: const Scaffold(
body: Center(child: Text('Chat - Coming soon')),
pageBuilder: const ChatListBuilder().buildPage,
routes: [
GoRoute(
path: 'conversation/:chatId',
name: 'legacy_chat_conversation',
pageBuilder: const ChatConversationBuilder().buildPage,
),
),
],
),
],
),

View File

@@ -6,13 +6,21 @@
#include "generated_plugin_registrant.h"
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin");
audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -3,7 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
record_linux
url_launcher_linux
)

View File

@@ -64,6 +64,8 @@ dependencies:
path: ../../modules/legacy/modules/device_management
location:
path: ../../modules/legacy/modules/location
chat:
path: ../../modules/legacy/modules/chat
legacy_auth:
path: ../../modules/legacy/modules/legacy_auth
settings:

View File

@@ -0,0 +1,2 @@
export 'src/features/chat_list/chat_list_builder.dart';
export 'src/features/chat_conversation/chat_conversation_builder.dart';

View File

@@ -0,0 +1,8 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
abstract class ChatOfflineQueueDatasource {
Future<List<ChatMessageEntity>> load(String chatId);
Future<void> enqueue(String chatId, ChatMessageEntity message);
Future<void> remove(String chatId, String messageId);
Future<void> clear(String chatId);
}

View File

@@ -0,0 +1,104 @@
import 'dart:convert';
import 'package:chat/src/core/data/datasource/chat_offline_queue_datasource.dart';
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _prefix = 'legacy_chat_offline_queue_';
class ChatOfflineQueueDatasourceImpl implements ChatOfflineQueueDatasource {
String _key(String chatId) => '$_prefix$chatId';
@override
Future<List<ChatMessageEntity>> load(String chatId) async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key(chatId));
if (raw == null || raw.isEmpty) return [];
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => _entityFromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('[ChatOfflineQueue] failed to load: $e');
return [];
}
}
@override
Future<void> enqueue(String chatId, ChatMessageEntity message) async {
final current = await load(chatId);
final filtered = current.where((m) => m.id != message.id).toList()
..add(message);
await _save(chatId, filtered);
}
@override
Future<void> remove(String chatId, String messageId) async {
final current = await load(chatId);
final filtered = current.where((m) => m.id != messageId).toList();
if (filtered.length == current.length) return;
await _save(chatId, filtered);
}
@override
Future<void> clear(String chatId) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key(chatId));
} catch (e) {
debugPrint('[ChatOfflineQueue] failed to clear: $e');
}
}
Future<void> _save(String chatId, List<ChatMessageEntity> messages) async {
try {
final prefs = await SharedPreferences.getInstance();
if (messages.isEmpty) {
await prefs.remove(_key(chatId));
return;
}
final raw = jsonEncode(messages.map(_entityToJson).toList());
await prefs.setString(_key(chatId), raw);
} catch (e) {
debugPrint('[ChatOfflineQueue] failed to save: $e');
}
}
Map<String, dynamic> _entityToJson(ChatMessageEntity m) => {
'id': m.id,
'chatId': m.chatId,
'userId': m.userId,
'userName': m.userName,
'deviceIdentificator': m.deviceIdentificator,
'type': m.type.wireValue,
'content': m.content,
'createdAt': m.createdAt.millisecondsSinceEpoch,
'localFilePath': m.localFilePath,
'fileDurationMs': m.fileDurationMs,
'fileSizeBytes': m.fileSizeBytes,
};
ChatMessageEntity _entityFromJson(Map<String, dynamic> json) =>
ChatMessageEntity(
id: json['id'] as String,
chatId: json['chatId'] as String?,
userId: json['userId'] as String?,
userName: json['userName'] as String?,
deviceIdentificator: json['deviceIdentificator'] as String,
type: ChatMessageType.fromWire(json['type'] as String),
content: json['content'] as String? ?? '',
status: ChatMessageStatus.wait,
createdAt: DateTime.fromMillisecondsSinceEpoch(
json['createdAt'] as int,
),
isLocalOptimistic: true,
failed: true,
localFilePath: json['localFilePath'] as String?,
fileDurationMs: json['fileDurationMs'] as int?,
fileSizeBytes: json['fileSizeBytes'] as int?,
);
}

View File

@@ -0,0 +1,44 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:chat/src/core/domain/repositories/chat_repository.dart';
abstract class ChatRemoteDatasource {
Future<List<ChatMessageEntity>> listMessages({
required String deviceIdentificator,
String? chatId,
int page = 1,
int pageSize = 50,
});
Future<ChatMessageEntity> getMessage({required String id});
Future<ChatMessageEntity> sendTextMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required ChatMessageType type,
required String content,
required String userId,
required String userName,
});
Future<ChatMessageEntity> sendImageMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required String filePath,
required String userId,
required String userName,
ChatUploadProgress? onProgress,
});
Future<ChatMessageEntity> sendAudioMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required String filePath,
required String userId,
required String userName,
ChatUploadProgress? onProgress,
});
}

View File

@@ -0,0 +1,39 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_message_dto.freezed.dart';
part 'chat_message_dto.g.dart';
@freezed
abstract class ChatMessageDto with _$ChatMessageDto {
const factory ChatMessageDto({
required String id,
String? chatId,
String? userId,
String? userName,
required String deviceIdentificator,
required String type,
required String content,
required String status,
required int createdAt,
}) = _ChatMessageDto;
factory ChatMessageDto.fromJson(Map<String, dynamic> json) =>
_$ChatMessageDtoFromJson(json);
}
extension ChatMessageDtoMapper on ChatMessageDto {
ChatMessageEntity toEntity() => ChatMessageEntity(
id: id,
chatId: chatId,
userId: userId,
userName: userName,
deviceIdentificator: deviceIdentificator,
type: ChatMessageType.fromWire(type),
content: content,
status: ChatMessageStatus.fromWire(status),
createdAt: DateTime.fromMillisecondsSinceEpoch(createdAt),
);
}

View File

@@ -0,0 +1,301 @@
// 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_message_dto.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatMessageDto {
String get id; String? get chatId; String? get userId; String? get userName; String get deviceIdentificator; String get type; String get content; String get status; int get createdAt;
/// Create a copy of ChatMessageDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatMessageDtoCopyWith<ChatMessageDto> get copyWith => _$ChatMessageDtoCopyWithImpl<ChatMessageDto>(this as ChatMessageDto, _$identity);
/// Serializes this ChatMessageDto to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatMessageDto&&(identical(other.id, id) || other.id == id)&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,chatId,userId,userName,deviceIdentificator,type,content,status,createdAt);
@override
String toString() {
return 'ChatMessageDto(id: $id, chatId: $chatId, userId: $userId, userName: $userName, deviceIdentificator: $deviceIdentificator, type: $type, content: $content, status: $status, createdAt: $createdAt)';
}
}
/// @nodoc
abstract mixin class $ChatMessageDtoCopyWith<$Res> {
factory $ChatMessageDtoCopyWith(ChatMessageDto value, $Res Function(ChatMessageDto) _then) = _$ChatMessageDtoCopyWithImpl;
@useResult
$Res call({
String id, String? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt
});
}
/// @nodoc
class _$ChatMessageDtoCopyWithImpl<$Res>
implements $ChatMessageDtoCopyWith<$Res> {
_$ChatMessageDtoCopyWithImpl(this._self, this._then);
final ChatMessageDto _self;
final $Res Function(ChatMessageDto) _then;
/// Create a copy of ChatMessageDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? chatId = freezed,Object? userId = freezed,Object? userName = freezed,Object? deviceIdentificator = null,Object? type = null,Object? content = null,Object? status = null,Object? createdAt = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,chatId: freezed == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String?,userName: freezed == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String?,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [ChatMessageDto].
extension ChatMessageDtoPatterns on ChatMessageDto {
/// 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( _ChatMessageDto value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChatMessageDto() 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( _ChatMessageDto value) $default,){
final _that = this;
switch (_that) {
case _ChatMessageDto():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( _ChatMessageDto value)? $default,){
final _that = this;
switch (_that) {
case _ChatMessageDto() 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( String id, String? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatMessageDto() when $default != null:
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt);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( String id, String? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt) $default,) {final _that = this;
switch (_that) {
case _ChatMessageDto():
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( String id, String? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt)? $default,) {final _that = this;
switch (_that) {
case _ChatMessageDto() when $default != null:
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ChatMessageDto implements ChatMessageDto {
const _ChatMessageDto({required this.id, this.chatId, this.userId, this.userName, required this.deviceIdentificator, required this.type, required this.content, required this.status, required this.createdAt});
factory _ChatMessageDto.fromJson(Map<String, dynamic> json) => _$ChatMessageDtoFromJson(json);
@override final String id;
@override final String? chatId;
@override final String? userId;
@override final String? userName;
@override final String deviceIdentificator;
@override final String type;
@override final String content;
@override final String status;
@override final int createdAt;
/// Create a copy of ChatMessageDto
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChatMessageDtoCopyWith<_ChatMessageDto> get copyWith => __$ChatMessageDtoCopyWithImpl<_ChatMessageDto>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ChatMessageDtoToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatMessageDto&&(identical(other.id, id) || other.id == id)&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,chatId,userId,userName,deviceIdentificator,type,content,status,createdAt);
@override
String toString() {
return 'ChatMessageDto(id: $id, chatId: $chatId, userId: $userId, userName: $userName, deviceIdentificator: $deviceIdentificator, type: $type, content: $content, status: $status, createdAt: $createdAt)';
}
}
/// @nodoc
abstract mixin class _$ChatMessageDtoCopyWith<$Res> implements $ChatMessageDtoCopyWith<$Res> {
factory _$ChatMessageDtoCopyWith(_ChatMessageDto value, $Res Function(_ChatMessageDto) _then) = __$ChatMessageDtoCopyWithImpl;
@override @useResult
$Res call({
String id, String? chatId, String? userId, String? userName, String deviceIdentificator, String type, String content, String status, int createdAt
});
}
/// @nodoc
class __$ChatMessageDtoCopyWithImpl<$Res>
implements _$ChatMessageDtoCopyWith<$Res> {
__$ChatMessageDtoCopyWithImpl(this._self, this._then);
final _ChatMessageDto _self;
final $Res Function(_ChatMessageDto) _then;
/// Create a copy of ChatMessageDto
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? chatId = freezed,Object? userId = freezed,Object? userName = freezed,Object? deviceIdentificator = null,Object? type = null,Object? content = null,Object? status = null,Object? createdAt = null,}) {
return _then(_ChatMessageDto(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,chatId: freezed == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String?,userName: freezed == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String?,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
// dart format on

View File

@@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_message_dto.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ChatMessageDto _$ChatMessageDtoFromJson(Map<String, dynamic> json) =>
_ChatMessageDto(
id: json['id'] as String,
chatId: json['chatId'] as String?,
userId: json['userId'] as String?,
userName: json['userName'] as String?,
deviceIdentificator: json['deviceIdentificator'] as String,
type: json['type'] as String,
content: json['content'] as String,
status: json['status'] as String,
createdAt: (json['createdAt'] as num).toInt(),
);
Map<String, dynamic> _$ChatMessageDtoToJson(_ChatMessageDto instance) =>
<String, dynamic>{
'id': instance.id,
'chatId': instance.chatId,
'userId': instance.userId,
'userName': instance.userName,
'deviceIdentificator': instance.deviceIdentificator,
'type': instance.type,
'content': instance.content,
'status': instance.status,
'createdAt': instance.createdAt,
};

View File

@@ -0,0 +1,24 @@
import 'package:chat/src/core/data/models/chat_message_dto.dart';
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_messages_response_dto.freezed.dart';
part 'chat_messages_response_dto.g.dart';
@freezed
abstract class ChatMessagesResponseDto with _$ChatMessagesResponseDto {
const factory ChatMessagesResponseDto({
required List<ChatMessageDto> items,
int? total,
int? page,
int? pages,
}) = _ChatMessagesResponseDto;
factory ChatMessagesResponseDto.fromJson(Map<String, dynamic> json) =>
_$ChatMessagesResponseDtoFromJson(json);
}
extension ChatMessagesResponseDtoMapper on ChatMessagesResponseDto {
List<ChatMessageEntity> toEntities() =>
items.map((dto) => dto.toEntity()).toList();
}

View File

@@ -0,0 +1,292 @@
// 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_messages_response_dto.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatMessagesResponseDto {
List<ChatMessageDto> get items; int? get total; int? get page; int? get pages;
/// Create a copy of ChatMessagesResponseDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatMessagesResponseDtoCopyWith<ChatMessagesResponseDto> get copyWith => _$ChatMessagesResponseDtoCopyWithImpl<ChatMessagesResponseDto>(this as ChatMessagesResponseDto, _$identity);
/// Serializes this ChatMessagesResponseDto to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatMessagesResponseDto&&const DeepCollectionEquality().equals(other.items, items)&&(identical(other.total, total) || other.total == total)&&(identical(other.page, page) || other.page == page)&&(identical(other.pages, pages) || other.pages == pages));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(items),total,page,pages);
@override
String toString() {
return 'ChatMessagesResponseDto(items: $items, total: $total, page: $page, pages: $pages)';
}
}
/// @nodoc
abstract mixin class $ChatMessagesResponseDtoCopyWith<$Res> {
factory $ChatMessagesResponseDtoCopyWith(ChatMessagesResponseDto value, $Res Function(ChatMessagesResponseDto) _then) = _$ChatMessagesResponseDtoCopyWithImpl;
@useResult
$Res call({
List<ChatMessageDto> items, int? total, int? page, int? pages
});
}
/// @nodoc
class _$ChatMessagesResponseDtoCopyWithImpl<$Res>
implements $ChatMessagesResponseDtoCopyWith<$Res> {
_$ChatMessagesResponseDtoCopyWithImpl(this._self, this._then);
final ChatMessagesResponseDto _self;
final $Res Function(ChatMessagesResponseDto) _then;
/// Create a copy of ChatMessagesResponseDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? items = null,Object? total = freezed,Object? page = freezed,Object? pages = freezed,}) {
return _then(_self.copyWith(
items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
as List<ChatMessageDto>,total: freezed == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
as int?,page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
as int?,pages: freezed == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// Adds pattern-matching-related methods to [ChatMessagesResponseDto].
extension ChatMessagesResponseDtoPatterns on ChatMessagesResponseDto {
/// 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( _ChatMessagesResponseDto value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChatMessagesResponseDto() 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( _ChatMessagesResponseDto value) $default,){
final _that = this;
switch (_that) {
case _ChatMessagesResponseDto():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( _ChatMessagesResponseDto value)? $default,){
final _that = this;
switch (_that) {
case _ChatMessagesResponseDto() 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<ChatMessageDto> items, int? total, int? page, int? pages)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatMessagesResponseDto() when $default != null:
return $default(_that.items,_that.total,_that.page,_that.pages);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<ChatMessageDto> items, int? total, int? page, int? pages) $default,) {final _that = this;
switch (_that) {
case _ChatMessagesResponseDto():
return $default(_that.items,_that.total,_that.page,_that.pages);case _:
throw StateError('Unexpected subclass');
}
}
/// 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<ChatMessageDto> items, int? total, int? page, int? pages)? $default,) {final _that = this;
switch (_that) {
case _ChatMessagesResponseDto() when $default != null:
return $default(_that.items,_that.total,_that.page,_that.pages);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _ChatMessagesResponseDto implements ChatMessagesResponseDto {
const _ChatMessagesResponseDto({required final List<ChatMessageDto> items, this.total, this.page, this.pages}): _items = items;
factory _ChatMessagesResponseDto.fromJson(Map<String, dynamic> json) => _$ChatMessagesResponseDtoFromJson(json);
final List<ChatMessageDto> _items;
@override List<ChatMessageDto> get items {
if (_items is EqualUnmodifiableListView) return _items;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_items);
}
@override final int? total;
@override final int? page;
@override final int? pages;
/// Create a copy of ChatMessagesResponseDto
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChatMessagesResponseDtoCopyWith<_ChatMessagesResponseDto> get copyWith => __$ChatMessagesResponseDtoCopyWithImpl<_ChatMessagesResponseDto>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$ChatMessagesResponseDtoToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatMessagesResponseDto&&const DeepCollectionEquality().equals(other._items, _items)&&(identical(other.total, total) || other.total == total)&&(identical(other.page, page) || other.page == page)&&(identical(other.pages, pages) || other.pages == pages));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_items),total,page,pages);
@override
String toString() {
return 'ChatMessagesResponseDto(items: $items, total: $total, page: $page, pages: $pages)';
}
}
/// @nodoc
abstract mixin class _$ChatMessagesResponseDtoCopyWith<$Res> implements $ChatMessagesResponseDtoCopyWith<$Res> {
factory _$ChatMessagesResponseDtoCopyWith(_ChatMessagesResponseDto value, $Res Function(_ChatMessagesResponseDto) _then) = __$ChatMessagesResponseDtoCopyWithImpl;
@override @useResult
$Res call({
List<ChatMessageDto> items, int? total, int? page, int? pages
});
}
/// @nodoc
class __$ChatMessagesResponseDtoCopyWithImpl<$Res>
implements _$ChatMessagesResponseDtoCopyWith<$Res> {
__$ChatMessagesResponseDtoCopyWithImpl(this._self, this._then);
final _ChatMessagesResponseDto _self;
final $Res Function(_ChatMessagesResponseDto) _then;
/// Create a copy of ChatMessagesResponseDto
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? items = null,Object? total = freezed,Object? page = freezed,Object? pages = freezed,}) {
return _then(_ChatMessagesResponseDto(
items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
as List<ChatMessageDto>,total: freezed == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
as int?,page: freezed == page ? _self.page : page // ignore: cast_nullable_to_non_nullable
as int?,pages: freezed == pages ? _self.pages : pages // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
// dart format on

View File

@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_messages_response_dto.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ChatMessagesResponseDto _$ChatMessagesResponseDtoFromJson(
Map<String, dynamic> json,
) => _ChatMessagesResponseDto(
items: (json['items'] as List<dynamic>)
.map((e) => ChatMessageDto.fromJson(e as Map<String, dynamic>))
.toList(),
total: (json['total'] as num?)?.toInt(),
page: (json['page'] as num?)?.toInt(),
pages: (json['pages'] as num?)?.toInt(),
);
Map<String, dynamic> _$ChatMessagesResponseDtoToJson(
_ChatMessagesResponseDto instance,
) => <String, dynamic>{
'items': instance.items,
'total': instance.total,
'page': instance.page,
'pages': instance.pages,
};

View File

@@ -0,0 +1,27 @@
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:dio/dio.dart';
FormData buildSendChatMessageFormData({
required String id,
required String deviceIdentificator,
required ChatMessageType type,
required String content,
required String chatId,
required String userId,
required String userName,
MultipartFile? file,
}) {
final isTextual =
type == ChatMessageType.text || type == ChatMessageType.emoji;
return FormData.fromMap({
'id': id,
'deviceIdentificator': deviceIdentificator,
'type': type.wireValue,
if (isTextual) 'content': content,
if (file != null) 'file': file,
'chatId': chatId,
'userId': userId,
'userName': userName,
});
}

View File

@@ -0,0 +1,111 @@
import 'package:chat/src/core/data/datasource/chat_remote_datasource.dart';
import 'package:chat/src/core/data/utils/chat_file_url_builder.dart';
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:chat/src/core/domain/repositories/chat_repository.dart';
import 'package:dio/dio.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
class ChatRepositoryImpl implements ChatRepository {
const ChatRepositoryImpl(this._remote, this._urlBuilder);
final ChatRemoteDatasource _remote;
final ChatFileUrlBuilder _urlBuilder;
@override
Future<List<ChatMessageEntity>> listMessages({
required String deviceIdentificator,
String? chatId,
int page = 1,
int pageSize = 50,
}) async {
try {
return await _remote.listMessages(
deviceIdentificator: deviceIdentificator,
chatId: chatId,
page: page,
pageSize: pageSize,
);
} on DioException catch (e) {
if (e.response?.statusCode == 404) return [];
throw mapDioError(e, defaultMessage: 'Error loading chat messages');
}
}
@override
Future<ChatMessageEntity> getMessage({required String id}) =>
safeCall(() => _remote.getMessage(id: id), 'Error loading chat message');
@override
Future<ChatMessageEntity> sendTextMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required ChatMessageType type,
required String content,
required String userId,
required String userName,
}) => safeCall(
() => _remote.sendTextMessage(
id: id,
deviceIdentificator: deviceIdentificator,
chatId: chatId,
type: type,
content: content,
userId: userId,
userName: userName,
),
'Error sending chat message',
);
@override
Future<ChatMessageEntity> sendImageMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required String filePath,
required String userId,
required String userName,
ChatUploadProgress? onProgress,
}) => safeCall(
() => _remote.sendImageMessage(
id: id,
deviceIdentificator: deviceIdentificator,
chatId: chatId,
filePath: filePath,
userId: userId,
userName: userName,
onProgress: onProgress,
),
'Error sending image message',
);
@override
Future<ChatMessageEntity> sendAudioMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required String filePath,
required int durationMs,
required String userId,
required String userName,
ChatUploadProgress? onProgress,
}) async {
final entity = await safeCall(
() => _remote.sendAudioMessage(
id: id,
deviceIdentificator: deviceIdentificator,
chatId: chatId,
filePath: filePath,
userId: userId,
userName: userName,
onProgress: onProgress,
),
'Error sending audio message',
);
return entity.copyWith(fileDurationMs: durationMs);
}
@override
String fileUrlForId(String fileId) => _urlBuilder.urlForFileId(fileId);
}

View File

@@ -0,0 +1,12 @@
class ChatFileUrlBuilder {
const ChatFileUrlBuilder(this._baseUrl);
final String _baseUrl;
String urlForFileId(String fileId) {
final base = _baseUrl.endsWith('/')
? _baseUrl.substring(0, _baseUrl.length - 1)
: _baseUrl;
return '$base/devices/static-files/$fileId';
}
}

View File

@@ -0,0 +1,25 @@
import 'dart:io';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path_provider/path_provider.dart';
const int _maxDimension = 1024;
const int _quality = 85;
Future<File> compressChatImage(String sourcePath) async {
final tempDir = await getTemporaryDirectory();
final targetPath =
'${tempDir.path}/chat_image_${DateTime.now().millisecondsSinceEpoch}.jpg';
final compressed = await FlutterImageCompress.compressAndGetFile(
sourcePath,
targetPath,
minWidth: _maxDimension,
minHeight: _maxDimension,
quality: _quality,
format: CompressFormat.jpeg,
);
if (compressed == null) return File(sourcePath);
return File(compressed.path);
}

View File

@@ -0,0 +1,23 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_conversation_entity.freezed.dart';
@freezed
sealed class ChatConversationEntity with _$ChatConversationEntity {
const factory ChatConversationEntity.oneToOne({
required String chatId,
required String deviceId,
required String deviceIdentificator,
required String displayName,
String? avatarBackgroundImageId,
ChatMessageEntity? lastMessage,
}) = OneToOneConversation;
const factory ChatConversationEntity.familyGroup({
required String chatId,
required String displayName,
required List<String> memberIdentificators,
ChatMessageEntity? lastMessage,
}) = FamilyGroupConversation;
}

View File

@@ -0,0 +1,397 @@
// 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_conversation_entity.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatConversationEntity {
String get chatId; String get displayName; ChatMessageEntity? get lastMessage;
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatConversationEntityCopyWith<ChatConversationEntity> get copyWith => _$ChatConversationEntityCopyWithImpl<ChatConversationEntity>(this as ChatConversationEntity, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatConversationEntity&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lastMessage, lastMessage) || other.lastMessage == lastMessage));
}
@override
int get hashCode => Object.hash(runtimeType,chatId,displayName,lastMessage);
@override
String toString() {
return 'ChatConversationEntity(chatId: $chatId, displayName: $displayName, lastMessage: $lastMessage)';
}
}
/// @nodoc
abstract mixin class $ChatConversationEntityCopyWith<$Res> {
factory $ChatConversationEntityCopyWith(ChatConversationEntity value, $Res Function(ChatConversationEntity) _then) = _$ChatConversationEntityCopyWithImpl;
@useResult
$Res call({
String chatId, String displayName, ChatMessageEntity? lastMessage
});
$ChatMessageEntityCopyWith<$Res>? get lastMessage;
}
/// @nodoc
class _$ChatConversationEntityCopyWithImpl<$Res>
implements $ChatConversationEntityCopyWith<$Res> {
_$ChatConversationEntityCopyWithImpl(this._self, this._then);
final ChatConversationEntity _self;
final $Res Function(ChatConversationEntity) _then;
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? chatId = null,Object? displayName = null,Object? lastMessage = freezed,}) {
return _then(_self.copyWith(
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,lastMessage: freezed == lastMessage ? _self.lastMessage : lastMessage // ignore: cast_nullable_to_non_nullable
as ChatMessageEntity?,
));
}
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ChatMessageEntityCopyWith<$Res>? get lastMessage {
if (_self.lastMessage == null) {
return null;
}
return $ChatMessageEntityCopyWith<$Res>(_self.lastMessage!, (value) {
return _then(_self.copyWith(lastMessage: value));
});
}
}
/// Adds pattern-matching-related methods to [ChatConversationEntity].
extension ChatConversationEntityPatterns on ChatConversationEntity {
/// 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( OneToOneConversation value)? oneToOne,TResult Function( FamilyGroupConversation value)? familyGroup,required TResult orElse(),}){
final _that = this;
switch (_that) {
case OneToOneConversation() when oneToOne != null:
return oneToOne(_that);case FamilyGroupConversation() when familyGroup != null:
return familyGroup(_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?>({required TResult Function( OneToOneConversation value) oneToOne,required TResult Function( FamilyGroupConversation value) familyGroup,}){
final _that = this;
switch (_that) {
case OneToOneConversation():
return oneToOne(_that);case FamilyGroupConversation():
return familyGroup(_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( OneToOneConversation value)? oneToOne,TResult? Function( FamilyGroupConversation value)? familyGroup,}){
final _that = this;
switch (_that) {
case OneToOneConversation() when oneToOne != null:
return oneToOne(_that);case FamilyGroupConversation() when familyGroup != null:
return familyGroup(_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( String chatId, String deviceId, String deviceIdentificator, String displayName, String? avatarBackgroundImageId, ChatMessageEntity? lastMessage)? oneToOne,TResult Function( String chatId, String displayName, List<String> memberIdentificators, ChatMessageEntity? lastMessage)? familyGroup,required TResult orElse(),}) {final _that = this;
switch (_that) {
case OneToOneConversation() when oneToOne != null:
return oneToOne(_that.chatId,_that.deviceId,_that.deviceIdentificator,_that.displayName,_that.avatarBackgroundImageId,_that.lastMessage);case FamilyGroupConversation() when familyGroup != null:
return familyGroup(_that.chatId,_that.displayName,_that.memberIdentificators,_that.lastMessage);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?>({required TResult Function( String chatId, String deviceId, String deviceIdentificator, String displayName, String? avatarBackgroundImageId, ChatMessageEntity? lastMessage) oneToOne,required TResult Function( String chatId, String displayName, List<String> memberIdentificators, ChatMessageEntity? lastMessage) familyGroup,}) {final _that = this;
switch (_that) {
case OneToOneConversation():
return oneToOne(_that.chatId,_that.deviceId,_that.deviceIdentificator,_that.displayName,_that.avatarBackgroundImageId,_that.lastMessage);case FamilyGroupConversation():
return familyGroup(_that.chatId,_that.displayName,_that.memberIdentificators,_that.lastMessage);}
}
/// 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( String chatId, String deviceId, String deviceIdentificator, String displayName, String? avatarBackgroundImageId, ChatMessageEntity? lastMessage)? oneToOne,TResult? Function( String chatId, String displayName, List<String> memberIdentificators, ChatMessageEntity? lastMessage)? familyGroup,}) {final _that = this;
switch (_that) {
case OneToOneConversation() when oneToOne != null:
return oneToOne(_that.chatId,_that.deviceId,_that.deviceIdentificator,_that.displayName,_that.avatarBackgroundImageId,_that.lastMessage);case FamilyGroupConversation() when familyGroup != null:
return familyGroup(_that.chatId,_that.displayName,_that.memberIdentificators,_that.lastMessage);case _:
return null;
}
}
}
/// @nodoc
class OneToOneConversation implements ChatConversationEntity {
const OneToOneConversation({required this.chatId, required this.deviceId, required this.deviceIdentificator, required this.displayName, this.avatarBackgroundImageId, this.lastMessage});
@override final String chatId;
final String deviceId;
final String deviceIdentificator;
@override final String displayName;
final String? avatarBackgroundImageId;
@override final ChatMessageEntity? lastMessage;
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$OneToOneConversationCopyWith<OneToOneConversation> get copyWith => _$OneToOneConversationCopyWithImpl<OneToOneConversation>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is OneToOneConversation&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.avatarBackgroundImageId, avatarBackgroundImageId) || other.avatarBackgroundImageId == avatarBackgroundImageId)&&(identical(other.lastMessage, lastMessage) || other.lastMessage == lastMessage));
}
@override
int get hashCode => Object.hash(runtimeType,chatId,deviceId,deviceIdentificator,displayName,avatarBackgroundImageId,lastMessage);
@override
String toString() {
return 'ChatConversationEntity.oneToOne(chatId: $chatId, deviceId: $deviceId, deviceIdentificator: $deviceIdentificator, displayName: $displayName, avatarBackgroundImageId: $avatarBackgroundImageId, lastMessage: $lastMessage)';
}
}
/// @nodoc
abstract mixin class $OneToOneConversationCopyWith<$Res> implements $ChatConversationEntityCopyWith<$Res> {
factory $OneToOneConversationCopyWith(OneToOneConversation value, $Res Function(OneToOneConversation) _then) = _$OneToOneConversationCopyWithImpl;
@override @useResult
$Res call({
String chatId, String deviceId, String deviceIdentificator, String displayName, String? avatarBackgroundImageId, ChatMessageEntity? lastMessage
});
@override $ChatMessageEntityCopyWith<$Res>? get lastMessage;
}
/// @nodoc
class _$OneToOneConversationCopyWithImpl<$Res>
implements $OneToOneConversationCopyWith<$Res> {
_$OneToOneConversationCopyWithImpl(this._self, this._then);
final OneToOneConversation _self;
final $Res Function(OneToOneConversation) _then;
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? chatId = null,Object? deviceId = null,Object? deviceIdentificator = null,Object? displayName = null,Object? avatarBackgroundImageId = freezed,Object? lastMessage = freezed,}) {
return _then(OneToOneConversation(
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,avatarBackgroundImageId: freezed == avatarBackgroundImageId ? _self.avatarBackgroundImageId : avatarBackgroundImageId // ignore: cast_nullable_to_non_nullable
as String?,lastMessage: freezed == lastMessage ? _self.lastMessage : lastMessage // ignore: cast_nullable_to_non_nullable
as ChatMessageEntity?,
));
}
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ChatMessageEntityCopyWith<$Res>? get lastMessage {
if (_self.lastMessage == null) {
return null;
}
return $ChatMessageEntityCopyWith<$Res>(_self.lastMessage!, (value) {
return _then(_self.copyWith(lastMessage: value));
});
}
}
/// @nodoc
class FamilyGroupConversation implements ChatConversationEntity {
const FamilyGroupConversation({required this.chatId, required this.displayName, required final List<String> memberIdentificators, this.lastMessage}): _memberIdentificators = memberIdentificators;
@override final String chatId;
@override final String displayName;
final List<String> _memberIdentificators;
List<String> get memberIdentificators {
if (_memberIdentificators is EqualUnmodifiableListView) return _memberIdentificators;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_memberIdentificators);
}
@override final ChatMessageEntity? lastMessage;
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$FamilyGroupConversationCopyWith<FamilyGroupConversation> get copyWith => _$FamilyGroupConversationCopyWithImpl<FamilyGroupConversation>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is FamilyGroupConversation&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&const DeepCollectionEquality().equals(other._memberIdentificators, _memberIdentificators)&&(identical(other.lastMessage, lastMessage) || other.lastMessage == lastMessage));
}
@override
int get hashCode => Object.hash(runtimeType,chatId,displayName,const DeepCollectionEquality().hash(_memberIdentificators),lastMessage);
@override
String toString() {
return 'ChatConversationEntity.familyGroup(chatId: $chatId, displayName: $displayName, memberIdentificators: $memberIdentificators, lastMessage: $lastMessage)';
}
}
/// @nodoc
abstract mixin class $FamilyGroupConversationCopyWith<$Res> implements $ChatConversationEntityCopyWith<$Res> {
factory $FamilyGroupConversationCopyWith(FamilyGroupConversation value, $Res Function(FamilyGroupConversation) _then) = _$FamilyGroupConversationCopyWithImpl;
@override @useResult
$Res call({
String chatId, String displayName, List<String> memberIdentificators, ChatMessageEntity? lastMessage
});
@override $ChatMessageEntityCopyWith<$Res>? get lastMessage;
}
/// @nodoc
class _$FamilyGroupConversationCopyWithImpl<$Res>
implements $FamilyGroupConversationCopyWith<$Res> {
_$FamilyGroupConversationCopyWithImpl(this._self, this._then);
final FamilyGroupConversation _self;
final $Res Function(FamilyGroupConversation) _then;
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? chatId = null,Object? displayName = null,Object? memberIdentificators = null,Object? lastMessage = freezed,}) {
return _then(FamilyGroupConversation(
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,memberIdentificators: null == memberIdentificators ? _self._memberIdentificators : memberIdentificators // ignore: cast_nullable_to_non_nullable
as List<String>,lastMessage: freezed == lastMessage ? _self.lastMessage : lastMessage // ignore: cast_nullable_to_non_nullable
as ChatMessageEntity?,
));
}
/// Create a copy of ChatConversationEntity
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ChatMessageEntityCopyWith<$Res>? get lastMessage {
if (_self.lastMessage == null) {
return null;
}
return $ChatMessageEntityCopyWith<$Res>(_self.lastMessage!, (value) {
return _then(_self.copyWith(lastMessage: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,30 @@
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_message_entity.freezed.dart';
@freezed
abstract class ChatMessageEntity with _$ChatMessageEntity {
const ChatMessageEntity._();
const factory ChatMessageEntity({
required String id,
String? chatId,
String? userId,
String? userName,
required String deviceIdentificator,
required ChatMessageType type,
required String content,
required ChatMessageStatus status,
required DateTime createdAt,
@Default(false) bool isLocalOptimistic,
@Default(false) bool failed,
String? localFilePath,
@Default(0.0) double uploadProgress,
int? fileDurationMs,
int? fileSizeBytes,
}) = _ChatMessageEntity;
bool get isOutgoing => userId != null;
}

View File

@@ -0,0 +1,313 @@
// 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_message_entity.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatMessageEntity {
String get id; String? get chatId; String? get userId; String? get userName; String get deviceIdentificator; ChatMessageType get type; String get content; ChatMessageStatus get status; DateTime get createdAt; bool get isLocalOptimistic; bool get failed; String? get localFilePath; double get uploadProgress; int? get fileDurationMs; int? get fileSizeBytes;
/// Create a copy of ChatMessageEntity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatMessageEntityCopyWith<ChatMessageEntity> get copyWith => _$ChatMessageEntityCopyWithImpl<ChatMessageEntity>(this as ChatMessageEntity, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatMessageEntity&&(identical(other.id, id) || other.id == id)&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isLocalOptimistic, isLocalOptimistic) || other.isLocalOptimistic == isLocalOptimistic)&&(identical(other.failed, failed) || other.failed == failed)&&(identical(other.localFilePath, localFilePath) || other.localFilePath == localFilePath)&&(identical(other.uploadProgress, uploadProgress) || other.uploadProgress == uploadProgress)&&(identical(other.fileDurationMs, fileDurationMs) || other.fileDurationMs == fileDurationMs)&&(identical(other.fileSizeBytes, fileSizeBytes) || other.fileSizeBytes == fileSizeBytes));
}
@override
int get hashCode => Object.hash(runtimeType,id,chatId,userId,userName,deviceIdentificator,type,content,status,createdAt,isLocalOptimistic,failed,localFilePath,uploadProgress,fileDurationMs,fileSizeBytes);
@override
String toString() {
return 'ChatMessageEntity(id: $id, chatId: $chatId, userId: $userId, userName: $userName, deviceIdentificator: $deviceIdentificator, type: $type, content: $content, status: $status, createdAt: $createdAt, isLocalOptimistic: $isLocalOptimistic, failed: $failed, localFilePath: $localFilePath, uploadProgress: $uploadProgress, fileDurationMs: $fileDurationMs, fileSizeBytes: $fileSizeBytes)';
}
}
/// @nodoc
abstract mixin class $ChatMessageEntityCopyWith<$Res> {
factory $ChatMessageEntityCopyWith(ChatMessageEntity value, $Res Function(ChatMessageEntity) _then) = _$ChatMessageEntityCopyWithImpl;
@useResult
$Res call({
String id, String? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes
});
}
/// @nodoc
class _$ChatMessageEntityCopyWithImpl<$Res>
implements $ChatMessageEntityCopyWith<$Res> {
_$ChatMessageEntityCopyWithImpl(this._self, this._then);
final ChatMessageEntity _self;
final $Res Function(ChatMessageEntity) _then;
/// Create a copy of ChatMessageEntity
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? chatId = freezed,Object? userId = freezed,Object? userName = freezed,Object? deviceIdentificator = null,Object? type = null,Object? content = null,Object? status = null,Object? createdAt = null,Object? isLocalOptimistic = null,Object? failed = null,Object? localFilePath = freezed,Object? uploadProgress = null,Object? fileDurationMs = freezed,Object? fileSizeBytes = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,chatId: freezed == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String?,userName: freezed == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String?,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as ChatMessageType,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as ChatMessageStatus,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,isLocalOptimistic: null == isLocalOptimistic ? _self.isLocalOptimistic : isLocalOptimistic // ignore: cast_nullable_to_non_nullable
as bool,failed: null == failed ? _self.failed : failed // ignore: cast_nullable_to_non_nullable
as bool,localFilePath: freezed == localFilePath ? _self.localFilePath : localFilePath // ignore: cast_nullable_to_non_nullable
as String?,uploadProgress: null == uploadProgress ? _self.uploadProgress : uploadProgress // ignore: cast_nullable_to_non_nullable
as double,fileDurationMs: freezed == fileDurationMs ? _self.fileDurationMs : fileDurationMs // ignore: cast_nullable_to_non_nullable
as int?,fileSizeBytes: freezed == fileSizeBytes ? _self.fileSizeBytes : fileSizeBytes // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// Adds pattern-matching-related methods to [ChatMessageEntity].
extension ChatMessageEntityPatterns on ChatMessageEntity {
/// 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( _ChatMessageEntity value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChatMessageEntity() 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( _ChatMessageEntity value) $default,){
final _that = this;
switch (_that) {
case _ChatMessageEntity():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( _ChatMessageEntity value)? $default,){
final _that = this;
switch (_that) {
case _ChatMessageEntity() 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( String id, String? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatMessageEntity() when $default != null:
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt,_that.isLocalOptimistic,_that.failed,_that.localFilePath,_that.uploadProgress,_that.fileDurationMs,_that.fileSizeBytes);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( String id, String? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes) $default,) {final _that = this;
switch (_that) {
case _ChatMessageEntity():
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt,_that.isLocalOptimistic,_that.failed,_that.localFilePath,_that.uploadProgress,_that.fileDurationMs,_that.fileSizeBytes);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( String id, String? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes)? $default,) {final _that = this;
switch (_that) {
case _ChatMessageEntity() when $default != null:
return $default(_that.id,_that.chatId,_that.userId,_that.userName,_that.deviceIdentificator,_that.type,_that.content,_that.status,_that.createdAt,_that.isLocalOptimistic,_that.failed,_that.localFilePath,_that.uploadProgress,_that.fileDurationMs,_that.fileSizeBytes);case _:
return null;
}
}
}
/// @nodoc
class _ChatMessageEntity extends ChatMessageEntity {
const _ChatMessageEntity({required this.id, this.chatId, this.userId, this.userName, required this.deviceIdentificator, required this.type, required this.content, required this.status, required this.createdAt, this.isLocalOptimistic = false, this.failed = false, this.localFilePath, this.uploadProgress = 0.0, this.fileDurationMs, this.fileSizeBytes}): super._();
@override final String id;
@override final String? chatId;
@override final String? userId;
@override final String? userName;
@override final String deviceIdentificator;
@override final ChatMessageType type;
@override final String content;
@override final ChatMessageStatus status;
@override final DateTime createdAt;
@override@JsonKey() final bool isLocalOptimistic;
@override@JsonKey() final bool failed;
@override final String? localFilePath;
@override@JsonKey() final double uploadProgress;
@override final int? fileDurationMs;
@override final int? fileSizeBytes;
/// Create a copy of ChatMessageEntity
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChatMessageEntityCopyWith<_ChatMessageEntity> get copyWith => __$ChatMessageEntityCopyWithImpl<_ChatMessageEntity>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatMessageEntity&&(identical(other.id, id) || other.id == id)&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.userName, userName) || other.userName == userName)&&(identical(other.deviceIdentificator, deviceIdentificator) || other.deviceIdentificator == deviceIdentificator)&&(identical(other.type, type) || other.type == type)&&(identical(other.content, content) || other.content == content)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.isLocalOptimistic, isLocalOptimistic) || other.isLocalOptimistic == isLocalOptimistic)&&(identical(other.failed, failed) || other.failed == failed)&&(identical(other.localFilePath, localFilePath) || other.localFilePath == localFilePath)&&(identical(other.uploadProgress, uploadProgress) || other.uploadProgress == uploadProgress)&&(identical(other.fileDurationMs, fileDurationMs) || other.fileDurationMs == fileDurationMs)&&(identical(other.fileSizeBytes, fileSizeBytes) || other.fileSizeBytes == fileSizeBytes));
}
@override
int get hashCode => Object.hash(runtimeType,id,chatId,userId,userName,deviceIdentificator,type,content,status,createdAt,isLocalOptimistic,failed,localFilePath,uploadProgress,fileDurationMs,fileSizeBytes);
@override
String toString() {
return 'ChatMessageEntity(id: $id, chatId: $chatId, userId: $userId, userName: $userName, deviceIdentificator: $deviceIdentificator, type: $type, content: $content, status: $status, createdAt: $createdAt, isLocalOptimistic: $isLocalOptimistic, failed: $failed, localFilePath: $localFilePath, uploadProgress: $uploadProgress, fileDurationMs: $fileDurationMs, fileSizeBytes: $fileSizeBytes)';
}
}
/// @nodoc
abstract mixin class _$ChatMessageEntityCopyWith<$Res> implements $ChatMessageEntityCopyWith<$Res> {
factory _$ChatMessageEntityCopyWith(_ChatMessageEntity value, $Res Function(_ChatMessageEntity) _then) = __$ChatMessageEntityCopyWithImpl;
@override @useResult
$Res call({
String id, String? chatId, String? userId, String? userName, String deviceIdentificator, ChatMessageType type, String content, ChatMessageStatus status, DateTime createdAt, bool isLocalOptimistic, bool failed, String? localFilePath, double uploadProgress, int? fileDurationMs, int? fileSizeBytes
});
}
/// @nodoc
class __$ChatMessageEntityCopyWithImpl<$Res>
implements _$ChatMessageEntityCopyWith<$Res> {
__$ChatMessageEntityCopyWithImpl(this._self, this._then);
final _ChatMessageEntity _self;
final $Res Function(_ChatMessageEntity) _then;
/// Create a copy of ChatMessageEntity
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? chatId = freezed,Object? userId = freezed,Object? userName = freezed,Object? deviceIdentificator = null,Object? type = null,Object? content = null,Object? status = null,Object? createdAt = null,Object? isLocalOptimistic = null,Object? failed = null,Object? localFilePath = freezed,Object? uploadProgress = null,Object? fileDurationMs = freezed,Object? fileSizeBytes = freezed,}) {
return _then(_ChatMessageEntity(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,chatId: freezed == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String?,userId: freezed == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String?,userName: freezed == userName ? _self.userName : userName // ignore: cast_nullable_to_non_nullable
as String?,deviceIdentificator: null == deviceIdentificator ? _self.deviceIdentificator : deviceIdentificator // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as ChatMessageType,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as ChatMessageStatus,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,isLocalOptimistic: null == isLocalOptimistic ? _self.isLocalOptimistic : isLocalOptimistic // ignore: cast_nullable_to_non_nullable
as bool,failed: null == failed ? _self.failed : failed // ignore: cast_nullable_to_non_nullable
as bool,localFilePath: freezed == localFilePath ? _self.localFilePath : localFilePath // ignore: cast_nullable_to_non_nullable
as String?,uploadProgress: null == uploadProgress ? _self.uploadProgress : uploadProgress // ignore: cast_nullable_to_non_nullable
as double,fileDurationMs: freezed == fileDurationMs ? _self.fileDurationMs : fileDurationMs // ignore: cast_nullable_to_non_nullable
as int?,fileSizeBytes: freezed == fileSizeBytes ? _self.fileSizeBytes : fileSizeBytes // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
// dart format on

View File

@@ -0,0 +1 @@
enum ChatMediaSource { camera, gallery }

View File

@@ -0,0 +1,13 @@
enum ChatMessageStatus {
wait,
send,
delivered;
static ChatMessageStatus fromWire(String value) =>
ChatMessageStatus.values.firstWhere(
(e) => e.name == value,
orElse: () => ChatMessageStatus.wait,
);
bool get isPending => this != ChatMessageStatus.delivered;
}

View File

@@ -0,0 +1,14 @@
enum ChatMessageType {
text,
emoji,
image,
audio;
String get wireValue => name;
static ChatMessageType fromWire(String value) =>
ChatMessageType.values.firstWhere(
(e) => e.wireValue == value,
orElse: () => ChatMessageType.text,
);
}

View File

@@ -0,0 +1,48 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
typedef ChatUploadProgress = void Function(int sent, int total);
abstract class ChatRepository {
Future<List<ChatMessageEntity>> listMessages({
required String deviceIdentificator,
String? chatId,
int page = 1,
int pageSize = 50,
});
Future<ChatMessageEntity> getMessage({required String id});
Future<ChatMessageEntity> sendTextMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required ChatMessageType type,
required String content,
required String userId,
required String userName,
});
Future<ChatMessageEntity> sendImageMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required String filePath,
required String userId,
required String userName,
ChatUploadProgress? onProgress,
});
Future<ChatMessageEntity> sendAudioMessage({
required String id,
required String deviceIdentificator,
required String chatId,
required String filePath,
required int durationMs,
required String userId,
required String userName,
ChatUploadProgress? onProgress,
});
String fileUrlForId(String fileId);
}

View File

@@ -0,0 +1,22 @@
import 'package:uuid/uuid.dart';
class ChatIdResolver {
const ChatIdResolver();
static const _oneToOnePrefix = '1to1_';
static const _familyPrefix = 'family_';
String oneToOneChatId({
required String userId,
required String deviceIdentificator,
}) => '$_oneToOnePrefix${userId}_$deviceIdentificator';
String familyChatId({required String userId, String? delegationId}) =>
'$_familyPrefix${delegationId ?? userId}';
String newChatMessageId() => const Uuid().v4();
bool isOneToOne(String chatId) => chatId.startsWith(_oneToOnePrefix);
bool isFamilyGroup(String chatId) => chatId.startsWith(_familyPrefix);
}

View File

@@ -0,0 +1,32 @@
import 'package:permission_handler/permission_handler.dart';
enum ChatPermissionResult { granted, denied, permanentlyDenied }
ChatPermissionResult _mapStatus(PermissionStatus status) {
if (status.isGranted || status.isLimited) return ChatPermissionResult.granted;
if (status.isPermanentlyDenied) {
return ChatPermissionResult.permanentlyDenied;
}
return ChatPermissionResult.denied;
}
class ChatPermissions {
const ChatPermissions();
Future<ChatPermissionResult> ensureCamera() async {
final status = await Permission.camera.request();
return _mapStatus(status);
}
Future<ChatPermissionResult> ensureMicrophone() async {
final status = await Permission.microphone.request();
return _mapStatus(status);
}
Future<ChatPermissionResult> ensurePhotos() async {
final status = await Permission.photos.request();
return _mapStatus(status);
}
Future<bool> openSettings() => openAppSettings();
}

View File

@@ -0,0 +1,9 @@
import 'package:chat/src/core/data/utils/chat_file_url_builder.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get_it/get_it.dart';
final chatFileUrlBuilderProvider = Provider<ChatFileUrlBuilder>((ref) {
final dio = GetIt.I<Dio>();
return ChatFileUrlBuilder(dio.options.baseUrl);
});

View File

@@ -0,0 +1,6 @@
import 'package:chat/src/core/domain/services/chat_id_resolver.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final chatIdResolverProvider = Provider<ChatIdResolver>(
(ref) => const ChatIdResolver(),
);

View File

@@ -0,0 +1,7 @@
import 'package:chat/src/core/data/datasource/chat_offline_queue_datasource.dart';
import 'package:chat/src/core/data/datasource/chat_offline_queue_datasource_impl.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final chatOfflineQueueProvider = Provider<ChatOfflineQueueDatasource>(
(ref) => ChatOfflineQueueDatasourceImpl(),
);

View File

@@ -0,0 +1,6 @@
import 'package:chat/src/core/domain/services/chat_permissions.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final chatPermissionsProvider = Provider<ChatPermissions>(
(ref) => const ChatPermissions(),
);

View File

@@ -0,0 +1,8 @@
import 'package:chat/src/core/data/datasource/chat_remote_datasource.dart';
import 'package:chat/src/core/data/datasource/chat_remote_datasource_impl.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
final chatRemoteDatasourceProvider = Provider<ChatRemoteDatasource>((ref) {
return ChatRemoteDatasourceImpl(getIt<SaveFamilyRepository>());
});

View File

@@ -0,0 +1,12 @@
import 'package:chat/src/core/data/repositories/chat_repository_impl.dart';
import 'package:chat/src/core/domain/repositories/chat_repository.dart';
import 'package:chat/src/core/providers/chat_file_url_builder_provider.dart';
import 'package:chat/src/core/providers/chat_remote_datasource_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final chatRepositoryProvider = Provider<ChatRepository>((ref) {
return ChatRepositoryImpl(
ref.read(chatRemoteDatasourceProvider),
ref.read(chatFileUrlBuilderProvider),
);
});

View File

@@ -0,0 +1,22 @@
import 'package:chat/src/core/providers/chat_id_resolver_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_shared/sf_shared.dart';
final familyChatIdProvider = Provider<String?>((ref) {
final user = ref.watch(userInfoProvider).value;
if (user == null) return null;
return ref
.read(chatIdResolverProvider)
.familyChatId(userId: user.id, delegationId: user.delegationId);
});
final familyChatMembersProvider = Provider<List<DeviceEntity>>((ref) {
final devices = ref.watch(legacyDevicesProvider).value ?? const [];
final user = ref.watch(userInfoProvider).value;
if (user == null) return const [];
final delegationId = user.delegationId;
if (delegationId == null) return devices;
return devices.where((d) => d.delegationId == delegationId).toList();
});

View File

@@ -0,0 +1,15 @@
import 'package:chat/src/features/chat_conversation/presentation/chat_conversation_screen.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class ChatConversationBuilder {
const ChatConversationBuilder();
Page<void> buildPage(BuildContext context, GoRouterState state) {
final chatId = state.pathParameters['chatId'] ?? '';
return MaterialPage<void>(
key: state.pageKey,
child: ChatConversationScreen(chatId: chatId),
);
}
}

View File

@@ -0,0 +1,200 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_controller.dart';
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_state.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_app_bar.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_date_separator.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_input_bar.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_message_bubble.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
class ChatConversationScreen extends ConsumerStatefulWidget {
final String chatId;
const ChatConversationScreen({super.key, required this.chatId});
@override
ConsumerState<ChatConversationScreen> createState() =>
_ChatConversationScreenState();
}
class _ChatConversationScreenState
extends ConsumerState<ChatConversationScreen> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(chatConversationControllerProvider(widget.chatId).notifier).loadMore();
}
}
@override
Widget build(BuildContext context) {
final asyncState = ref.watch(chatConversationControllerProvider(widget.chatId));
ref.listen(chatConversationControllerProvider(widget.chatId), (
previous,
next,
) {
final state = next.value;
if (state == null) return;
final errorKey = state.displayErrorKey;
if (errorKey != null) {
showErrorDialog(context, errorKey);
ref
.read(chatConversationControllerProvider(widget.chatId).notifier)
.clearErrorEvent();
}
});
return asyncState.when(
loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (_, __) => Scaffold(
body: Center(
child: Text(context.translate(I18n.errorChatLoadConversation)),
),
),
data: (state) {
final title = state.title ??
(state.kind == ChatConversationKind.familyGroup
? context.translate(I18n.chatFamilyGroupTitle)
: context.translate(I18n.chatConversationTitleFallback));
final leadingIcon = state.kind == ChatConversationKind.familyGroup
? Icons.groups_outlined
: Icons.person_outline;
return Scaffold(
appBar: ChatAppBar(title: title, leadingIcon: leadingIcon),
body: Column(
children: [
Expanded(child: _MessagesList(state: state, scrollController: _scrollController, chatId: widget.chatId)),
ChatInputBar(
isSending: state.isSending,
onSendText: (text) => ref
.read(
chatConversationControllerProvider(widget.chatId).notifier,
)
.sendText(text),
onSendImage: (path, source) => ref
.read(
chatConversationControllerProvider(widget.chatId).notifier,
)
.sendImage(filePath: path, source: source),
onSendAudio: ({required filePath, required durationMs}) => ref
.read(
chatConversationControllerProvider(widget.chatId).notifier,
)
.sendAudio(filePath: filePath, durationMs: durationMs),
),
],
),
);
},
);
}
}
class _MessagesList extends ConsumerWidget {
final ChatConversationState state;
final ScrollController scrollController;
final String chatId;
const _MessagesList({
required this.state,
required this.scrollController,
required this.chatId,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (state.messages.isEmpty && !state.isLoadingMore) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
context.translate(I18n.chatConversationEmpty),
textAlign: TextAlign.center,
),
),
);
}
final items = _buildItems(state.messages);
return ListView.builder(
controller: scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (state.isLoadingMore && index == items.length) {
return const Padding(
padding: EdgeInsets.all(12),
child: Center(child: CircularProgressIndicator()),
);
}
final entry = items[index];
return switch (entry) {
_MessageItem(:final message) => ChatMessageBubble(
message: message,
onRetry: message.failed
? () => ref
.read(chatConversationControllerProvider(chatId).notifier)
.retryFailed(message.id)
: null,
),
_DateSeparatorItem(:final date) => ChatDateSeparator(date: date),
};
},
);
}
List<_FeedItem> _buildItems(List<ChatMessageEntity> messages) {
final items = <_FeedItem>[];
DateTime? previousDate;
for (final message in messages) {
final messageDay = DateTime(
message.createdAt.year,
message.createdAt.month,
message.createdAt.day,
);
if (previousDate != null && previousDate != messageDay) {
items.add(_DateSeparatorItem(previousDate));
}
items.add(_MessageItem(message));
previousDate = messageDay;
}
if (previousDate != null) items.add(_DateSeparatorItem(previousDate));
return items;
}
}
sealed class _FeedItem {
const _FeedItem();
}
class _MessageItem extends _FeedItem {
final ChatMessageEntity message;
const _MessageItem(this.message);
}
class _DateSeparatorItem extends _FeedItem {
final DateTime date;
const _DateSeparatorItem(this.date);
}

View File

@@ -0,0 +1,88 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:chat/src/core/providers/chat_repository_provider.dart';
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_audio_player_state.dart';
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_controller.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_audio_player_controller.g.dart';
@riverpod
class ChatAudioPlayerController extends _$ChatAudioPlayerController {
AudioPlayer? _player;
StreamSubscription<PlayerState>? _stateSub;
StreamSubscription<Duration>? _durationSub;
StreamSubscription<Duration>? _positionSub;
@override
ChatAudioPlayerState build(
String messageId, {
required Duration initialDuration,
}) {
ref.onDispose(() {
_stateSub?.cancel();
_durationSub?.cancel();
_positionSub?.cancel();
_player?.dispose();
_player = null;
});
ref.listen<String?>(currentlyPlayingAudioProvider, (_, next) {
if (next != messageId && state.isPlaying) {
_player?.pause();
}
});
return ChatAudioPlayerState(total: initialDuration);
}
Future<void> toggle({String? localFilePath, required String fileId}) async {
if (state.isPreparing) return;
final player = _ensurePlayer();
if (state.isPlaying) {
await player.pause();
ref
.read(currentlyPlayingAudioProvider.notifier)
.clearIf(messageId);
return;
}
state = state.copyWith(isPreparing: true);
try {
ref.read(currentlyPlayingAudioProvider.notifier).setPlaying(messageId);
if (localFilePath != null) {
await player.play(DeviceFileSource(localFilePath));
} else {
final url = ref.read(chatRepositoryProvider).fileUrlForId(fileId);
final cached = await DefaultCacheManager().getSingleFile(url);
await player.play(DeviceFileSource(cached.path));
}
} finally {
state = state.copyWith(isPreparing: false);
}
}
AudioPlayer _ensurePlayer() {
if (_player != null) return _player!;
final player = AudioPlayer();
_player = player;
_stateSub = player.onPlayerStateChanged.listen((s) {
state = state.copyWith(isPlaying: s == PlayerState.playing);
if (s == PlayerState.completed) {
state = state.copyWith(position: Duration.zero);
ref
.read(currentlyPlayingAudioProvider.notifier)
.clearIf(messageId);
}
});
_durationSub = player.onDurationChanged.listen((d) {
state = state.copyWith(total: d);
});
_positionSub = player.onPositionChanged.listen((p) {
state = state.copyWith(position: p);
});
return player;
}
}

View File

@@ -0,0 +1,120 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_audio_player_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatAudioPlayerController)
const chatAudioPlayerControllerProvider = ChatAudioPlayerControllerFamily._();
final class ChatAudioPlayerControllerProvider
extends $NotifierProvider<ChatAudioPlayerController, ChatAudioPlayerState> {
const ChatAudioPlayerControllerProvider._({
required ChatAudioPlayerControllerFamily super.from,
required (String, {Duration initialDuration}) super.argument,
}) : super(
retry: null,
name: r'chatAudioPlayerControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatAudioPlayerControllerHash();
@override
String toString() {
return r'chatAudioPlayerControllerProvider'
''
'$argument';
}
@$internal
@override
ChatAudioPlayerController create() => ChatAudioPlayerController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ChatAudioPlayerState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ChatAudioPlayerState>(value),
);
}
@override
bool operator ==(Object other) {
return other is ChatAudioPlayerControllerProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatAudioPlayerControllerHash() =>
r'97e90f7c8365693c27169aef847d1476013692d1';
final class ChatAudioPlayerControllerFamily extends $Family
with
$ClassFamilyOverride<
ChatAudioPlayerController,
ChatAudioPlayerState,
ChatAudioPlayerState,
ChatAudioPlayerState,
(String, {Duration initialDuration})
> {
const ChatAudioPlayerControllerFamily._()
: super(
retry: null,
name: r'chatAudioPlayerControllerProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatAudioPlayerControllerProvider call(
String messageId, {
required Duration initialDuration,
}) => ChatAudioPlayerControllerProvider._(
argument: (messageId, initialDuration: initialDuration),
from: this,
);
@override
String toString() => r'chatAudioPlayerControllerProvider';
}
abstract class _$ChatAudioPlayerController
extends $Notifier<ChatAudioPlayerState> {
late final _$args = ref.$arg as (String, {Duration initialDuration});
String get messageId => _$args.$1;
Duration get initialDuration => _$args.initialDuration;
ChatAudioPlayerState build(
String messageId, {
required Duration initialDuration,
});
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args.$1, initialDuration: _$args.initialDuration);
final ref = this.ref as $Ref<ChatAudioPlayerState, ChatAudioPlayerState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ChatAudioPlayerState, ChatAudioPlayerState>,
ChatAudioPlayerState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_audio_player_state.freezed.dart';
@freezed
abstract class ChatAudioPlayerState with _$ChatAudioPlayerState {
const factory ChatAudioPlayerState({
@Default(false) bool isPlaying,
@Default(false) bool isPreparing,
@Default(Duration.zero) Duration position,
@Default(Duration.zero) Duration total,
}) = _ChatAudioPlayerState;
}

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_audio_player_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatAudioPlayerState {
bool get isPlaying; bool get isPreparing; Duration get position; Duration get total;
/// Create a copy of ChatAudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatAudioPlayerStateCopyWith<ChatAudioPlayerState> get copyWith => _$ChatAudioPlayerStateCopyWithImpl<ChatAudioPlayerState>(this as ChatAudioPlayerState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatAudioPlayerState&&(identical(other.isPlaying, isPlaying) || other.isPlaying == isPlaying)&&(identical(other.isPreparing, isPreparing) || other.isPreparing == isPreparing)&&(identical(other.position, position) || other.position == position)&&(identical(other.total, total) || other.total == total));
}
@override
int get hashCode => Object.hash(runtimeType,isPlaying,isPreparing,position,total);
@override
String toString() {
return 'ChatAudioPlayerState(isPlaying: $isPlaying, isPreparing: $isPreparing, position: $position, total: $total)';
}
}
/// @nodoc
abstract mixin class $ChatAudioPlayerStateCopyWith<$Res> {
factory $ChatAudioPlayerStateCopyWith(ChatAudioPlayerState value, $Res Function(ChatAudioPlayerState) _then) = _$ChatAudioPlayerStateCopyWithImpl;
@useResult
$Res call({
bool isPlaying, bool isPreparing, Duration position, Duration total
});
}
/// @nodoc
class _$ChatAudioPlayerStateCopyWithImpl<$Res>
implements $ChatAudioPlayerStateCopyWith<$Res> {
_$ChatAudioPlayerStateCopyWithImpl(this._self, this._then);
final ChatAudioPlayerState _self;
final $Res Function(ChatAudioPlayerState) _then;
/// Create a copy of ChatAudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isPlaying = null,Object? isPreparing = null,Object? position = null,Object? total = null,}) {
return _then(_self.copyWith(
isPlaying: null == isPlaying ? _self.isPlaying : isPlaying // ignore: cast_nullable_to_non_nullable
as bool,isPreparing: null == isPreparing ? _self.isPreparing : isPreparing // ignore: cast_nullable_to_non_nullable
as bool,position: null == position ? _self.position : position // ignore: cast_nullable_to_non_nullable
as Duration,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
as Duration,
));
}
}
/// Adds pattern-matching-related methods to [ChatAudioPlayerState].
extension ChatAudioPlayerStatePatterns on ChatAudioPlayerState {
/// 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( _ChatAudioPlayerState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChatAudioPlayerState() 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( _ChatAudioPlayerState value) $default,){
final _that = this;
switch (_that) {
case _ChatAudioPlayerState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( _ChatAudioPlayerState value)? $default,){
final _that = this;
switch (_that) {
case _ChatAudioPlayerState() 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 isPlaying, bool isPreparing, Duration position, Duration total)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatAudioPlayerState() when $default != null:
return $default(_that.isPlaying,_that.isPreparing,_that.position,_that.total);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 isPlaying, bool isPreparing, Duration position, Duration total) $default,) {final _that = this;
switch (_that) {
case _ChatAudioPlayerState():
return $default(_that.isPlaying,_that.isPreparing,_that.position,_that.total);case _:
throw StateError('Unexpected subclass');
}
}
/// 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 isPlaying, bool isPreparing, Duration position, Duration total)? $default,) {final _that = this;
switch (_that) {
case _ChatAudioPlayerState() when $default != null:
return $default(_that.isPlaying,_that.isPreparing,_that.position,_that.total);case _:
return null;
}
}
}
/// @nodoc
class _ChatAudioPlayerState implements ChatAudioPlayerState {
const _ChatAudioPlayerState({this.isPlaying = false, this.isPreparing = false, this.position = Duration.zero, this.total = Duration.zero});
@override@JsonKey() final bool isPlaying;
@override@JsonKey() final bool isPreparing;
@override@JsonKey() final Duration position;
@override@JsonKey() final Duration total;
/// Create a copy of ChatAudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChatAudioPlayerStateCopyWith<_ChatAudioPlayerState> get copyWith => __$ChatAudioPlayerStateCopyWithImpl<_ChatAudioPlayerState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatAudioPlayerState&&(identical(other.isPlaying, isPlaying) || other.isPlaying == isPlaying)&&(identical(other.isPreparing, isPreparing) || other.isPreparing == isPreparing)&&(identical(other.position, position) || other.position == position)&&(identical(other.total, total) || other.total == total));
}
@override
int get hashCode => Object.hash(runtimeType,isPlaying,isPreparing,position,total);
@override
String toString() {
return 'ChatAudioPlayerState(isPlaying: $isPlaying, isPreparing: $isPreparing, position: $position, total: $total)';
}
}
/// @nodoc
abstract mixin class _$ChatAudioPlayerStateCopyWith<$Res> implements $ChatAudioPlayerStateCopyWith<$Res> {
factory _$ChatAudioPlayerStateCopyWith(_ChatAudioPlayerState value, $Res Function(_ChatAudioPlayerState) _then) = __$ChatAudioPlayerStateCopyWithImpl;
@override @useResult
$Res call({
bool isPlaying, bool isPreparing, Duration position, Duration total
});
}
/// @nodoc
class __$ChatAudioPlayerStateCopyWithImpl<$Res>
implements _$ChatAudioPlayerStateCopyWith<$Res> {
__$ChatAudioPlayerStateCopyWithImpl(this._self, this._then);
final _ChatAudioPlayerState _self;
final $Res Function(_ChatAudioPlayerState) _then;
/// Create a copy of ChatAudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isPlaying = null,Object? isPreparing = null,Object? position = null,Object? total = null,}) {
return _then(_ChatAudioPlayerState(
isPlaying: null == isPlaying ? _self.isPlaying : isPlaying // ignore: cast_nullable_to_non_nullable
as bool,isPreparing: null == isPreparing ? _self.isPreparing : isPreparing // ignore: cast_nullable_to_non_nullable
as bool,position: null == position ? _self.position : position // ignore: cast_nullable_to_non_nullable
as Duration,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
as Duration,
));
}
}
// dart format on

View File

@@ -0,0 +1,755 @@
import 'dart:async';
import 'dart:io';
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/domain/enums/chat_media_source.dart';
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:chat/src/core/providers/chat_id_resolver_provider.dart';
import 'package:chat/src/core/providers/chat_offline_queue_provider.dart';
import 'package:chat/src/core/providers/chat_repository_provider.dart';
import 'package:chat/src/core/providers/family_chat_provider.dart';
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_conversation_state.dart';
import 'package:legacy_device_state/legacy_device_state.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
part 'chat_conversation_controller.g.dart';
const _pollInterval = Duration(seconds: 4);
const _pageSize = 50;
const _orphanThreshold = Duration(hours: 1);
const _retryBackoff = Duration(seconds: 3);
const _retryAttempts = 2;
const _maxConsecutivePollErrors = 3;
// TODO(backend): coordinar la heurística push `phone.endsWith(chatId)` para que
// los formatos `1to1_<userId>_<deviceIdentificator>` y `family_<delegationId>`
// reciban push correctamente. Sin este fix, los push de mensajes propios
// reconciliados no llegan al teléfono. Bloqueante para release.
// TODO(backend): pedir que el `pushData` del backend incluya `chatId` explícito.
// TODO(backend): confirmar tope de tamaño y mime types soportados (hoy
// asumimos sin límite y reaccionamos a 413/415).
@riverpod
class ChatConversationController extends _$ChatConversationController {
Timer? _pollTimer;
StreamSubscription<WebSocketEvent>? _wsSubscription;
int _consecutivePollErrors = 0;
@override
Future<ChatConversationState> build(String chatId) async {
ref.onDispose(() {
_pollTimer?.cancel();
_pollTimer = null;
_wsSubscription?.cancel();
_wsSubscription = null;
});
unawaited(_cleanupOrphanAudioFiles());
final resolver = ref.read(chatIdResolverProvider);
final isOneToOne = resolver.isOneToOne(chatId);
final kind = isOneToOne
? ChatConversationKind.oneToOne
: ChatConversationKind.familyGroup;
final participants = await _resolveParticipants(isOneToOne: isOneToOne);
if (participants.identificators.isEmpty) {
return ChatConversationState(
chatId: chatId,
kind: kind,
title: participants.title,
errorEvent: ChatConversationErrorEvent.deviceNotFound,
);
}
final initial = await _fetchPage(
chatId: chatId,
participants: participants.identificators,
page: 1,
);
final queued = await ref.read(chatOfflineQueueProvider).load(chatId);
final messages = _mergeMessages([...initial.messages, ...queued]);
_maybeStartPolling(
chatId: chatId,
participants: participants.identificators,
);
_attachWebSocketListener(
chatId: chatId,
participants: participants.identificators,
);
unawaited(
ref.read(sfTrackingProvider).legacyChatOpened(isGroup: !isOneToOne),
);
final initialState = ChatConversationState(
chatId: chatId,
kind: kind,
title: participants.title,
participantsIdentificators: participants.identificators,
messages: messages,
hasMore: initial.hasMore,
currentPage: 1,
);
if (queued.isNotEmpty) unawaited(_drainOfflineQueue(queued));
return initialState;
}
Future<bool> sendText(String content) =>
_sendTextual(content: content, type: ChatMessageType.text);
Future<bool> sendEmoji(String emoji) =>
_sendTextual(content: emoji, type: ChatMessageType.emoji);
Future<bool> sendImage({
required String filePath,
required ChatMediaSource source,
}) => _sendMedia(
filePath: filePath,
type: ChatMessageType.image,
imageSource: source,
);
Future<bool> sendAudio({
required String filePath,
required int durationMs,
}) => _sendMedia(
filePath: filePath,
type: ChatMessageType.audio,
durationMs: durationMs,
);
Future<void> loadMore() async {
final current = state.value;
if (current == null || current.isLoadingMore || !current.hasMore) return;
if (current.participantsIdentificators.isEmpty) return;
state = AsyncData(current.copyWith(isLoadingMore: true));
final nextPage = current.currentPage + 1;
final result = await _fetchPage(
chatId: current.chatId,
participants: current.participantsIdentificators,
page: nextPage,
);
final merged = _mergeMessages([...current.messages, ...result.messages]);
state = AsyncData(
current.copyWith(
messages: merged,
currentPage: nextPage,
isLoadingMore: false,
hasMore: result.hasMore,
),
);
}
Future<void> reconcileFromRemote() async {
final current = state.value;
if (current == null) return;
if (current.participantsIdentificators.isEmpty) return;
final result = await _fetchPage(
chatId: current.chatId,
participants: current.participantsIdentificators,
page: 1,
);
final merged = _mergeMessages([...current.messages, ...result.messages]);
state = AsyncData(current.copyWith(messages: merged));
_maybeStartPolling(
chatId: current.chatId,
participants: current.participantsIdentificators,
);
}
Future<bool> retryFailed(String messageId) async {
final current = state.value;
if (current == null) return false;
final failed = current.messages.firstWhere(
(m) => m.id == messageId && m.failed,
orElse: () => current.messages.first,
);
if (failed.id != messageId || !failed.failed) return false;
return switch (failed.type) {
ChatMessageType.text || ChatMessageType.emoji => _sendTextual(
content: failed.content,
type: failed.type,
retryId: messageId,
),
ChatMessageType.image when failed.localFilePath != null => _sendMedia(
filePath: failed.localFilePath!,
type: ChatMessageType.image,
retryId: messageId,
),
ChatMessageType.audio when failed.localFilePath != null => _sendMedia(
filePath: failed.localFilePath!,
type: ChatMessageType.audio,
durationMs: failed.fileDurationMs ?? 0,
retryId: messageId,
),
_ => Future.value(false),
};
}
bool _shouldTrackSend(String? retryId) => retryId == null;
String _resolveUserName(UserEntity user) {
final fullName = '${user.firstName} ${user.lastName}'.trim();
if (fullName.isNotEmpty) return fullName;
if (user.email.isNotEmpty) return user.email;
return user.id;
}
void clearSuccessEvent() {
final current = state.value;
if (current == null) return;
state = AsyncData(current.copyWith(successEvent: null));
}
void clearErrorEvent() {
final current = state.value;
if (current == null) return;
state = AsyncData(current.copyWith(errorEvent: null));
}
Future<bool> _sendTextual({
required String content,
required ChatMessageType type,
String? retryId,
}) async {
final current = state.value;
if (current == null) return false;
if (current.participantsIdentificators.isEmpty) {
state = AsyncData(
current.copyWith(errorEvent: ChatConversationErrorEvent.deviceNotFound),
);
return false;
}
final user = await ref.read(userInfoProvider.future);
if (!ref.mounted) return false;
final resolver = ref.read(chatIdResolverProvider);
final messageId = retryId ?? resolver.newChatMessageId();
final now = DateTime.now();
final userName = _resolveUserName(user);
final optimistic = ChatMessageEntity(
id: messageId,
chatId: current.chatId,
userId: user.id,
userName: userName,
deviceIdentificator: current.participantsIdentificators.first,
type: type,
content: content,
status: ChatMessageStatus.wait,
createdAt: now,
isLocalOptimistic: true,
);
state = AsyncData(
current.copyWith(
messages: _upsertMessage(current.messages, optimistic),
isSending: true,
errorEvent: null,
successEvent: null,
),
);
final repo = ref.read(chatRepositoryProvider);
final isGroup = current.kind == ChatConversationKind.familyGroup;
final memberCount = current.participantsIdentificators.length;
final outcome = await _withRetry(() async {
final results = await Future.wait(
current.participantsIdentificators.map(
(deviceIdentificator) => repo.sendTextMessage(
id: deviceIdentificator == current.participantsIdentificators.first
? messageId
: resolver.newChatMessageId(),
deviceIdentificator: deviceIdentificator,
chatId: current.chatId,
type: type,
content: content,
userId: user.id,
userName: userName,
),
),
);
return results.first;
});
if (!ref.mounted) return outcome.success;
if (outcome.success) {
_onSendSuccess(messageId: messageId, primary: outcome.value!);
unawaited(
ref.read(chatOfflineQueueProvider).remove(current.chatId, messageId),
);
if (_shouldTrackSend(retryId)) {
unawaited(
ref.read(sfTrackingProvider).legacyChatMessageSent(
type: type.wireValue,
isGroup: isGroup,
memberCount: isGroup ? memberCount : null,
),
);
}
return true;
}
_onSendError(messageId: messageId, statusCode: outcome.statusCode);
unawaited(
ref
.read(chatOfflineQueueProvider)
.enqueue(current.chatId, optimistic.copyWith(failed: true)),
);
return false;
}
Future<bool> _sendMedia({
required String filePath,
required ChatMessageType type,
int? durationMs,
ChatMediaSource? imageSource,
String? retryId,
}) async {
final current = state.value;
if (current == null) return false;
if (current.participantsIdentificators.isEmpty) {
state = AsyncData(
current.copyWith(errorEvent: ChatConversationErrorEvent.deviceNotFound),
);
return false;
}
final user = await ref.read(userInfoProvider.future);
if (!ref.mounted) return false;
final resolver = ref.read(chatIdResolverProvider);
final messageId = retryId ?? resolver.newChatMessageId();
final now = DateTime.now();
final userName = _resolveUserName(user);
final fileSize = await _safeFileSize(filePath);
final isGroup = current.kind == ChatConversationKind.familyGroup;
final memberCount = current.participantsIdentificators.length;
final optimistic = ChatMessageEntity(
id: messageId,
chatId: current.chatId,
userId: user.id,
userName: userName,
deviceIdentificator: current.participantsIdentificators.first,
type: type,
content: '',
status: ChatMessageStatus.wait,
createdAt: now,
isLocalOptimistic: true,
localFilePath: filePath,
uploadProgress: 0,
fileDurationMs: durationMs,
fileSizeBytes: fileSize,
);
state = AsyncData(
current.copyWith(
messages: _upsertMessage(current.messages, optimistic),
isSending: true,
errorEvent: null,
successEvent: null,
),
);
final repo = ref.read(chatRepositoryProvider);
void Function(int sent, int total) progressFor(bool isPrimary) =>
(sent, total) {
if (!isPrimary || total <= 0) return;
final progress = sent / total;
final stateNow = state.value;
if (stateNow == null) return;
final updated = [
for (final m in stateNow.messages)
if (m.id == messageId) m.copyWith(uploadProgress: progress) else m,
];
state = AsyncData(stateNow.copyWith(messages: updated));
};
final outcome = await _withRetry(() async {
final results = await Future.wait([
for (var i = 0; i < current.participantsIdentificators.length; i++)
() {
final deviceIdentificator = current.participantsIdentificators[i];
final id = i == 0 ? messageId : resolver.newChatMessageId();
return type == ChatMessageType.image
? repo.sendImageMessage(
id: id,
deviceIdentificator: deviceIdentificator,
chatId: current.chatId,
filePath: filePath,
userId: user.id,
userName: userName,
onProgress: progressFor(i == 0),
)
: repo.sendAudioMessage(
id: id,
deviceIdentificator: deviceIdentificator,
chatId: current.chatId,
filePath: filePath,
durationMs: durationMs ?? 0,
userId: user.id,
userName: userName,
onProgress: progressFor(i == 0),
);
}(),
]);
return results.first.copyWith(
localFilePath: filePath,
fileDurationMs: durationMs,
fileSizeBytes: fileSize,
);
});
if (!ref.mounted) return outcome.success;
if (outcome.success) {
_onSendSuccess(messageId: messageId, primary: outcome.value!);
unawaited(
ref.read(chatOfflineQueueProvider).remove(current.chatId, messageId),
);
if (_shouldTrackSend(retryId)) {
if (type == ChatMessageType.image) {
unawaited(
ref.read(sfTrackingProvider).legacyChatImageSent(
source: imageSource?.name ?? 'unknown',
isGroup: isGroup,
memberCount: isGroup ? memberCount : null,
originalSizeBytes: fileSize,
),
);
} else {
unawaited(
ref.read(sfTrackingProvider).legacyChatAudioSent(
isGroup: isGroup,
memberCount: isGroup ? memberCount : null,
durationMs: durationMs ?? 0,
sizeBytes: fileSize,
),
);
}
}
return true;
}
_onSendError(messageId: messageId, statusCode: outcome.statusCode);
unawaited(
ref
.read(chatOfflineQueueProvider)
.enqueue(current.chatId, optimistic.copyWith(failed: true)),
);
return false;
}
Future<_SendOutcome<T>> _withRetry<T>(Future<T> Function() action) async {
int? lastStatusCode;
for (var attempt = 0; attempt < _retryAttempts; attempt++) {
try {
final value = await action();
return _SendOutcome.success(value);
} on ApiException catch (e) {
lastStatusCode = e.statusCode;
if (!e.isNetworkError) {
return _SendOutcome.failure(statusCode: e.statusCode);
}
if (attempt < _retryAttempts - 1) {
await Future<void>.delayed(_retryBackoff);
}
} catch (_) {
if (attempt < _retryAttempts - 1) {
await Future<void>.delayed(_retryBackoff);
}
}
}
return _SendOutcome.failure(statusCode: lastStatusCode);
}
void _onSendSuccess({
required String messageId,
required ChatMessageEntity primary,
}) {
final stateNow = state.value;
if (stateNow == null) return;
final updated = [
for (final m in stateNow.messages)
if (m.id == messageId)
primary.copyWith(
failed: false,
isLocalOptimistic: false,
uploadProgress: 1,
localFilePath: primary.localFilePath ?? m.localFilePath,
fileDurationMs: primary.fileDurationMs ?? m.fileDurationMs,
fileSizeBytes: primary.fileSizeBytes ?? m.fileSizeBytes,
)
else
m,
];
state = AsyncData(
stateNow.copyWith(
messages: updated,
isSending: false,
successEvent: ChatConversationSuccessEvent.messageSent,
),
);
_maybeStartPolling(
chatId: stateNow.chatId,
participants: stateNow.participantsIdentificators,
);
}
void _onSendError({required String messageId, int? statusCode}) {
final stateNow = state.value;
if (stateNow == null) return;
final updated = [
for (final m in stateNow.messages)
if (m.id == messageId) m.copyWith(failed: true) else m,
];
final error = switch (statusCode) {
403 => ChatConversationErrorEvent.sendForbidden,
413 => ChatConversationErrorEvent.fileTooLarge,
415 => ChatConversationErrorEvent.fileUnsupported,
_ => ChatConversationErrorEvent.sendFailed,
};
state = AsyncData(
stateNow.copyWith(
messages: updated,
isSending: false,
errorEvent: error,
),
);
}
List<ChatMessageEntity> _upsertMessage(
List<ChatMessageEntity> messages,
ChatMessageEntity message,
) =>
[message, ...messages.where((m) => m.id != message.id)];
Future<int?> _safeFileSize(String path) async {
try {
return await File(path).length();
} catch (_) {
return null;
}
}
Future<_ParticipantsResolution> _resolveParticipants({
required bool isOneToOne,
}) async {
if (isOneToOne) {
final selected = await ref.read(selectedDeviceProvider.future);
if (selected == null) {
return const _ParticipantsResolution(
identificators: [],
title: null,
);
}
return _ParticipantsResolution(
identificators: [selected.identificator],
title: selected.carrierName ?? selected.identificator,
);
}
final members = ref.read(familyChatMembersProvider);
return _ParticipantsResolution(
identificators: members.map((d) => d.identificator).toList(),
title: null,
);
}
Future<_PageResult> _fetchPage({
required String chatId,
required List<String> participants,
required int page,
}) async {
final repo = ref.read(chatRepositoryProvider);
try {
final perDevice = await Future.wait(
participants.map(
(deviceIdentificator) => repo.listMessages(
deviceIdentificator: deviceIdentificator,
chatId: chatId,
page: page,
pageSize: _pageSize,
),
),
);
final flattened = perDevice.expand((messages) => messages).toList();
final hasMore = perDevice.any((messages) => messages.length >= _pageSize);
return _PageResult(messages: flattened, hasMore: hasMore);
} catch (_) {
return const _PageResult(messages: [], hasMore: false, hasError: true);
}
}
List<ChatMessageEntity> _mergeMessages(List<ChatMessageEntity> all) {
final byId = <String, ChatMessageEntity>{};
for (final message in all) {
final existing = byId[message.id];
if (existing == null || existing.status.isPending) {
byId[message.id] = message;
}
}
final merged = byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return merged;
}
void _maybeStartPolling({
required String chatId,
required List<String> participants,
}) {
final hasPending = state.value?.messages.any(
(m) => !m.failed && m.status.isPending,
) ??
false;
if (!hasPending) {
_pollTimer?.cancel();
_pollTimer = null;
_consecutivePollErrors = 0;
return;
}
if (_pollTimer != null) return;
_consecutivePollErrors = 0;
_pollTimer = Timer.periodic(_pollInterval, (_) async {
final current = state.value;
if (current == null) return;
final stillPending = current.messages.any(
(m) => !m.failed && m.status.isPending,
);
if (!stillPending) {
_pollTimer?.cancel();
_pollTimer = null;
_consecutivePollErrors = 0;
return;
}
final result = await _fetchPage(
chatId: chatId,
participants: participants,
page: 1,
);
if (!ref.mounted) return;
if (result.hasError) {
_consecutivePollErrors++;
if (_consecutivePollErrors >= _maxConsecutivePollErrors) {
_pollTimer?.cancel();
_pollTimer = null;
}
return;
}
_consecutivePollErrors = 0;
final stateNow = state.value;
if (stateNow == null) return;
final merged = _mergeMessages([...stateNow.messages, ...result.messages]);
state = AsyncData(stateNow.copyWith(messages: merged));
});
}
void _attachWebSocketListener({
required String chatId,
required List<String> participants,
}) {
_wsSubscription?.cancel();
final ws = ref.read(webSocketServiceProvider);
_wsSubscription = ws.events.listen((event) {
if (event is! ChatMessageEvent) return;
final matchesChat = event.chatId == null || event.chatId == chatId;
final matchesDevice = participants.contains(event.deviceIdentificator);
if (!matchesChat || !matchesDevice) return;
reconcileFromRemote();
});
}
Future<void> _drainOfflineQueue(List<ChatMessageEntity> queued) async {
for (final pending in queued) {
if (!ref.mounted) return;
await retryFailed(pending.id);
}
}
Future<void> _cleanupOrphanAudioFiles() async {
try {
final tempDir = await getTemporaryDirectory();
final cutoff = DateTime.now().subtract(_orphanThreshold);
await for (final entity in tempDir.list()) {
if (entity is! File) continue;
final name = entity.path.split('/').last;
if (!name.startsWith('chat_audio_') && !name.startsWith('chat_image_')) {
continue;
}
final stat = await entity.stat();
if (stat.modified.isBefore(cutoff)) {
await entity.delete().catchError((_) => entity);
}
}
} catch (_) {}
}
}
@riverpod
class CurrentlyPlayingAudio extends _$CurrentlyPlayingAudio {
@override
String? build() => null;
void setPlaying(String messageId) => state = messageId;
void clearIf(String messageId) {
if (state == messageId) state = null;
}
}
class _ParticipantsResolution {
final List<String> identificators;
final String? title;
const _ParticipantsResolution({
required this.identificators,
required this.title,
});
}
class _PageResult {
final List<ChatMessageEntity> messages;
final bool hasMore;
final bool hasError;
const _PageResult({
required this.messages,
required this.hasMore,
this.hasError = false,
});
}
class _SendOutcome<T> {
final bool success;
final T? value;
final int? statusCode;
const _SendOutcome._({required this.success, this.value, this.statusCode});
factory _SendOutcome.success(T value) =>
_SendOutcome._(success: true, value: value);
factory _SendOutcome.failure({int? statusCode}) =>
_SendOutcome._(success: false, statusCode: statusCode);
}

View File

@@ -0,0 +1,166 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_conversation_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatConversationController)
const chatConversationControllerProvider = ChatConversationControllerFamily._();
final class ChatConversationControllerProvider
extends
$AsyncNotifierProvider<
ChatConversationController,
ChatConversationState
> {
const ChatConversationControllerProvider._({
required ChatConversationControllerFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'chatConversationControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatConversationControllerHash();
@override
String toString() {
return r'chatConversationControllerProvider'
''
'($argument)';
}
@$internal
@override
ChatConversationController create() => ChatConversationController();
@override
bool operator ==(Object other) {
return other is ChatConversationControllerProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$chatConversationControllerHash() =>
r'16e597322dd93f70fc86c6ae79ac3765e558ac69';
final class ChatConversationControllerFamily extends $Family
with
$ClassFamilyOverride<
ChatConversationController,
AsyncValue<ChatConversationState>,
ChatConversationState,
FutureOr<ChatConversationState>,
String
> {
const ChatConversationControllerFamily._()
: super(
retry: null,
name: r'chatConversationControllerProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ChatConversationControllerProvider call(String chatId) =>
ChatConversationControllerProvider._(argument: chatId, from: this);
@override
String toString() => r'chatConversationControllerProvider';
}
abstract class _$ChatConversationController
extends $AsyncNotifier<ChatConversationState> {
late final _$args = ref.$arg as String;
String get chatId => _$args;
FutureOr<ChatConversationState> build(String chatId);
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args);
final ref =
this.ref
as $Ref<AsyncValue<ChatConversationState>, ChatConversationState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<
AsyncValue<ChatConversationState>,
ChatConversationState
>,
AsyncValue<ChatConversationState>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@ProviderFor(CurrentlyPlayingAudio)
const currentlyPlayingAudioProvider = CurrentlyPlayingAudioProvider._();
final class CurrentlyPlayingAudioProvider
extends $NotifierProvider<CurrentlyPlayingAudio, String?> {
const CurrentlyPlayingAudioProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentlyPlayingAudioProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentlyPlayingAudioHash();
@$internal
@override
CurrentlyPlayingAudio create() => CurrentlyPlayingAudio();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$currentlyPlayingAudioHash() =>
r'9929190d7e8877add532a214a9621bca3ee3e69f';
abstract class _$CurrentlyPlayingAudio extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:sf_localizations/sf_localizations.dart';
part 'chat_conversation_state.freezed.dart';
enum ChatConversationKind { oneToOne, familyGroup }
enum ChatConversationSuccessEvent { messageSent }
enum ChatConversationErrorEvent {
loadFailed,
sendFailed,
sendForbidden,
deviceNotFound,
fileTooLarge,
fileUnsupported,
audioRecordingFailed,
imagePickFailed,
permissionCameraDenied,
permissionMicrophoneDenied,
permissionPhotosDenied,
}
@freezed
abstract class ChatConversationState with _$ChatConversationState {
const factory ChatConversationState({
required String chatId,
required ChatConversationKind kind,
@Default(<ChatMessageEntity>[]) List<ChatMessageEntity> messages,
@Default(false) bool isLoadingMore,
@Default(false) bool isSending,
@Default(true) bool hasMore,
@Default(1) int currentPage,
String? title,
@Default(<String>[]) List<String> participantsIdentificators,
ChatConversationSuccessEvent? successEvent,
ChatConversationErrorEvent? errorEvent,
}) = _ChatConversationState;
}
extension ChatConversationErrorEventDisplay on ChatConversationErrorEvent {
String? get displayKey => switch (this) {
ChatConversationErrorEvent.loadFailed => I18n.errorChatLoadConversation,
ChatConversationErrorEvent.sendFailed => I18n.errorChatSendMessage,
ChatConversationErrorEvent.sendForbidden => I18n.errorChatSendForbidden,
ChatConversationErrorEvent.deviceNotFound => I18n.errorChatDeviceNotFound,
ChatConversationErrorEvent.fileTooLarge => I18n.errorChatFileTooLarge,
ChatConversationErrorEvent.fileUnsupported => I18n.errorChatFileUnsupported,
ChatConversationErrorEvent.audioRecordingFailed =>
I18n.errorChatAudioRecordingFailed,
ChatConversationErrorEvent.imagePickFailed => I18n.errorChatImagePickFailed,
ChatConversationErrorEvent.permissionCameraDenied =>
I18n.chatPermissionCameraDenied,
ChatConversationErrorEvent.permissionMicrophoneDenied =>
I18n.chatPermissionMicrophoneDenied,
ChatConversationErrorEvent.permissionPhotosDenied =>
I18n.chatPermissionPhotosDenied,
};
}
extension ChatConversationStateDisplay on ChatConversationState {
String? get displayErrorKey => errorEvent?.displayKey;
}

View File

@@ -0,0 +1,313 @@
// 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_conversation_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatConversationState {
String get chatId; ChatConversationKind get kind; List<ChatMessageEntity> get messages; bool get isLoadingMore; bool get isSending; bool get hasMore; int get currentPage; String? get title; List<String> get participantsIdentificators; ChatConversationSuccessEvent? get successEvent; ChatConversationErrorEvent? get errorEvent;
/// Create a copy of ChatConversationState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatConversationStateCopyWith<ChatConversationState> get copyWith => _$ChatConversationStateCopyWithImpl<ChatConversationState>(this as ChatConversationState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatConversationState&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.kind, kind) || other.kind == kind)&&const DeepCollectionEquality().equals(other.messages, messages)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.isSending, isSending) || other.isSending == isSending)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other.participantsIdentificators, participantsIdentificators)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent));
}
@override
int get hashCode => Object.hash(runtimeType,chatId,kind,const DeepCollectionEquality().hash(messages),isLoadingMore,isSending,hasMore,currentPage,title,const DeepCollectionEquality().hash(participantsIdentificators),successEvent,errorEvent);
@override
String toString() {
return 'ChatConversationState(chatId: $chatId, kind: $kind, messages: $messages, isLoadingMore: $isLoadingMore, isSending: $isSending, hasMore: $hasMore, currentPage: $currentPage, title: $title, participantsIdentificators: $participantsIdentificators, successEvent: $successEvent, errorEvent: $errorEvent)';
}
}
/// @nodoc
abstract mixin class $ChatConversationStateCopyWith<$Res> {
factory $ChatConversationStateCopyWith(ChatConversationState value, $Res Function(ChatConversationState) _then) = _$ChatConversationStateCopyWithImpl;
@useResult
$Res call({
String chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent
});
}
/// @nodoc
class _$ChatConversationStateCopyWithImpl<$Res>
implements $ChatConversationStateCopyWith<$Res> {
_$ChatConversationStateCopyWithImpl(this._self, this._then);
final ChatConversationState _self;
final $Res Function(ChatConversationState) _then;
/// Create a copy of ChatConversationState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? chatId = null,Object? kind = null,Object? messages = null,Object? isLoadingMore = null,Object? isSending = null,Object? hasMore = null,Object? currentPage = null,Object? title = freezed,Object? participantsIdentificators = null,Object? successEvent = freezed,Object? errorEvent = freezed,}) {
return _then(_self.copyWith(
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String,kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable
as ChatConversationKind,messages: null == messages ? _self.messages : messages // ignore: cast_nullable_to_non_nullable
as List<ChatMessageEntity>,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable
as bool,isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable
as bool,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
as bool,currentPage: null == currentPage ? _self.currentPage : currentPage // ignore: cast_nullable_to_non_nullable
as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,participantsIdentificators: null == participantsIdentificators ? _self.participantsIdentificators : participantsIdentificators // ignore: cast_nullable_to_non_nullable
as List<String>,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
as ChatConversationSuccessEvent?,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
as ChatConversationErrorEvent?,
));
}
}
/// Adds pattern-matching-related methods to [ChatConversationState].
extension ChatConversationStatePatterns on ChatConversationState {
/// 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( _ChatConversationState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChatConversationState() 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( _ChatConversationState value) $default,){
final _that = this;
switch (_that) {
case _ChatConversationState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( _ChatConversationState value)? $default,){
final _that = this;
switch (_that) {
case _ChatConversationState() 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( String chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatConversationState() when $default != null:
return $default(_that.chatId,_that.kind,_that.messages,_that.isLoadingMore,_that.isSending,_that.hasMore,_that.currentPage,_that.title,_that.participantsIdentificators,_that.successEvent,_that.errorEvent);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( String chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent) $default,) {final _that = this;
switch (_that) {
case _ChatConversationState():
return $default(_that.chatId,_that.kind,_that.messages,_that.isLoadingMore,_that.isSending,_that.hasMore,_that.currentPage,_that.title,_that.participantsIdentificators,_that.successEvent,_that.errorEvent);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( String chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent)? $default,) {final _that = this;
switch (_that) {
case _ChatConversationState() when $default != null:
return $default(_that.chatId,_that.kind,_that.messages,_that.isLoadingMore,_that.isSending,_that.hasMore,_that.currentPage,_that.title,_that.participantsIdentificators,_that.successEvent,_that.errorEvent);case _:
return null;
}
}
}
/// @nodoc
class _ChatConversationState implements ChatConversationState {
const _ChatConversationState({required this.chatId, required this.kind, final List<ChatMessageEntity> messages = const <ChatMessageEntity>[], this.isLoadingMore = false, this.isSending = false, this.hasMore = true, this.currentPage = 1, this.title, final List<String> participantsIdentificators = const <String>[], this.successEvent, this.errorEvent}): _messages = messages,_participantsIdentificators = participantsIdentificators;
@override final String chatId;
@override final ChatConversationKind kind;
final List<ChatMessageEntity> _messages;
@override@JsonKey() List<ChatMessageEntity> get messages {
if (_messages is EqualUnmodifiableListView) return _messages;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_messages);
}
@override@JsonKey() final bool isLoadingMore;
@override@JsonKey() final bool isSending;
@override@JsonKey() final bool hasMore;
@override@JsonKey() final int currentPage;
@override final String? title;
final List<String> _participantsIdentificators;
@override@JsonKey() List<String> get participantsIdentificators {
if (_participantsIdentificators is EqualUnmodifiableListView) return _participantsIdentificators;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_participantsIdentificators);
}
@override final ChatConversationSuccessEvent? successEvent;
@override final ChatConversationErrorEvent? errorEvent;
/// Create a copy of ChatConversationState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChatConversationStateCopyWith<_ChatConversationState> get copyWith => __$ChatConversationStateCopyWithImpl<_ChatConversationState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatConversationState&&(identical(other.chatId, chatId) || other.chatId == chatId)&&(identical(other.kind, kind) || other.kind == kind)&&const DeepCollectionEquality().equals(other._messages, _messages)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&(identical(other.isSending, isSending) || other.isSending == isSending)&&(identical(other.hasMore, hasMore) || other.hasMore == hasMore)&&(identical(other.currentPage, currentPage) || other.currentPage == currentPage)&&(identical(other.title, title) || other.title == title)&&const DeepCollectionEquality().equals(other._participantsIdentificators, _participantsIdentificators)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent));
}
@override
int get hashCode => Object.hash(runtimeType,chatId,kind,const DeepCollectionEquality().hash(_messages),isLoadingMore,isSending,hasMore,currentPage,title,const DeepCollectionEquality().hash(_participantsIdentificators),successEvent,errorEvent);
@override
String toString() {
return 'ChatConversationState(chatId: $chatId, kind: $kind, messages: $messages, isLoadingMore: $isLoadingMore, isSending: $isSending, hasMore: $hasMore, currentPage: $currentPage, title: $title, participantsIdentificators: $participantsIdentificators, successEvent: $successEvent, errorEvent: $errorEvent)';
}
}
/// @nodoc
abstract mixin class _$ChatConversationStateCopyWith<$Res> implements $ChatConversationStateCopyWith<$Res> {
factory _$ChatConversationStateCopyWith(_ChatConversationState value, $Res Function(_ChatConversationState) _then) = __$ChatConversationStateCopyWithImpl;
@override @useResult
$Res call({
String chatId, ChatConversationKind kind, List<ChatMessageEntity> messages, bool isLoadingMore, bool isSending, bool hasMore, int currentPage, String? title, List<String> participantsIdentificators, ChatConversationSuccessEvent? successEvent, ChatConversationErrorEvent? errorEvent
});
}
/// @nodoc
class __$ChatConversationStateCopyWithImpl<$Res>
implements _$ChatConversationStateCopyWith<$Res> {
__$ChatConversationStateCopyWithImpl(this._self, this._then);
final _ChatConversationState _self;
final $Res Function(_ChatConversationState) _then;
/// Create a copy of ChatConversationState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? chatId = null,Object? kind = null,Object? messages = null,Object? isLoadingMore = null,Object? isSending = null,Object? hasMore = null,Object? currentPage = null,Object? title = freezed,Object? participantsIdentificators = null,Object? successEvent = freezed,Object? errorEvent = freezed,}) {
return _then(_ChatConversationState(
chatId: null == chatId ? _self.chatId : chatId // ignore: cast_nullable_to_non_nullable
as String,kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable
as ChatConversationKind,messages: null == messages ? _self._messages : messages // ignore: cast_nullable_to_non_nullable
as List<ChatMessageEntity>,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable
as bool,isSending: null == isSending ? _self.isSending : isSending // ignore: cast_nullable_to_non_nullable
as bool,hasMore: null == hasMore ? _self.hasMore : hasMore // ignore: cast_nullable_to_non_nullable
as bool,currentPage: null == currentPage ? _self.currentPage : currentPage // ignore: cast_nullable_to_non_nullable
as int,title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,participantsIdentificators: null == participantsIdentificators ? _self._participantsIdentificators : participantsIdentificators // ignore: cast_nullable_to_non_nullable
as List<String>,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
as ChatConversationSuccessEvent?,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
as ChatConversationErrorEvent?,
));
}
}
// dart format on

View File

@@ -0,0 +1,101 @@
import 'dart:async';
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_recorder_state.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_recorder_controller.g.dart';
@riverpod
class ChatRecorderController extends _$ChatRecorderController {
AudioRecorder? _recorder;
Timer? _ticker;
DateTime? _startedAt;
@override
ChatRecorderState build() {
ref.onDispose(() {
_ticker?.cancel();
_ticker = null;
_recorder?.dispose();
_recorder = null;
});
return const ChatRecorderState();
}
Future<bool> start() async {
if (state.isRecording) return false;
try {
_recorder ??= AudioRecorder();
final tempDir = await getTemporaryDirectory();
final path =
'${tempDir.path}/chat_audio_${DateTime.now().millisecondsSinceEpoch}.m4a';
await _recorder!.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: path,
);
_startedAt = DateTime.now();
_currentPath = path;
HapticFeedback.lightImpact();
state = const ChatRecorderState(isRecording: true);
_ticker?.cancel();
_ticker = Timer.periodic(const Duration(milliseconds: 200), (_) {
final start = _startedAt;
if (start == null) return;
state = state.copyWith(duration: DateTime.now().difference(start));
});
return true;
} catch (_) {
_resetTimers();
state = const ChatRecorderState();
return false;
}
}
void updateDrag(double dx) {
final clamped = dx.clamp(-200.0, 0.0);
final willCancel = dx < -60;
if (state.dragDx == clamped && state.willCancel == willCancel) return;
if (willCancel != state.willCancel) {
HapticFeedback.mediumImpact();
}
state = state.copyWith(dragDx: clamped, willCancel: willCancel);
}
Future<ChatRecordingResult> stop() async {
if (!state.isRecording) {
return const ChatRecordingResult(
path: null,
durationMs: 0,
wasCancelled: false,
);
}
final wasCancelled = state.willCancel;
final durationMs = state.duration.inMilliseconds;
_resetTimers();
final path = await _recorder?.stop();
state = const ChatRecorderState();
return ChatRecordingResult(
path: path ?? _currentPath,
durationMs: durationMs,
wasCancelled: wasCancelled,
);
}
Future<void> cancel() async {
if (!state.isRecording) return;
_resetTimers();
await _recorder?.cancel();
state = const ChatRecorderState();
}
void _resetTimers() {
_ticker?.cancel();
_ticker = null;
_startedAt = null;
}
String? _currentPath;
}

View File

@@ -0,0 +1,64 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_recorder_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatRecorderController)
const chatRecorderControllerProvider = ChatRecorderControllerProvider._();
final class ChatRecorderControllerProvider
extends $NotifierProvider<ChatRecorderController, ChatRecorderState> {
const ChatRecorderControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatRecorderControllerProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatRecorderControllerHash();
@$internal
@override
ChatRecorderController create() => ChatRecorderController();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ChatRecorderState value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ChatRecorderState>(value),
);
}
}
String _$chatRecorderControllerHash() =>
r'67ab36b23260990d95bc2bccaac187a3e35d9a52';
abstract class _$ChatRecorderController extends $Notifier<ChatRecorderState> {
ChatRecorderState build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<ChatRecorderState, ChatRecorderState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ChatRecorderState, ChatRecorderState>,
ChatRecorderState,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_recorder_state.freezed.dart';
@freezed
abstract class ChatRecorderState with _$ChatRecorderState {
const factory ChatRecorderState({
@Default(false) bool isRecording,
@Default(false) bool willCancel,
@Default(0.0) double dragDx,
@Default(Duration.zero) Duration duration,
}) = _ChatRecorderState;
}
class ChatRecordingResult {
final String? path;
final int durationMs;
final bool wasCancelled;
const ChatRecordingResult({
required this.path,
required this.durationMs,
required this.wasCancelled,
});
}

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_recorder_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatRecorderState {
bool get isRecording; bool get willCancel; double get dragDx; Duration get duration;
/// Create a copy of ChatRecorderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatRecorderStateCopyWith<ChatRecorderState> get copyWith => _$ChatRecorderStateCopyWithImpl<ChatRecorderState>(this as ChatRecorderState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatRecorderState&&(identical(other.isRecording, isRecording) || other.isRecording == isRecording)&&(identical(other.willCancel, willCancel) || other.willCancel == willCancel)&&(identical(other.dragDx, dragDx) || other.dragDx == dragDx)&&(identical(other.duration, duration) || other.duration == duration));
}
@override
int get hashCode => Object.hash(runtimeType,isRecording,willCancel,dragDx,duration);
@override
String toString() {
return 'ChatRecorderState(isRecording: $isRecording, willCancel: $willCancel, dragDx: $dragDx, duration: $duration)';
}
}
/// @nodoc
abstract mixin class $ChatRecorderStateCopyWith<$Res> {
factory $ChatRecorderStateCopyWith(ChatRecorderState value, $Res Function(ChatRecorderState) _then) = _$ChatRecorderStateCopyWithImpl;
@useResult
$Res call({
bool isRecording, bool willCancel, double dragDx, Duration duration
});
}
/// @nodoc
class _$ChatRecorderStateCopyWithImpl<$Res>
implements $ChatRecorderStateCopyWith<$Res> {
_$ChatRecorderStateCopyWithImpl(this._self, this._then);
final ChatRecorderState _self;
final $Res Function(ChatRecorderState) _then;
/// Create a copy of ChatRecorderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isRecording = null,Object? willCancel = null,Object? dragDx = null,Object? duration = null,}) {
return _then(_self.copyWith(
isRecording: null == isRecording ? _self.isRecording : isRecording // ignore: cast_nullable_to_non_nullable
as bool,willCancel: null == willCancel ? _self.willCancel : willCancel // ignore: cast_nullable_to_non_nullable
as bool,dragDx: null == dragDx ? _self.dragDx : dragDx // ignore: cast_nullable_to_non_nullable
as double,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,
));
}
}
/// Adds pattern-matching-related methods to [ChatRecorderState].
extension ChatRecorderStatePatterns on ChatRecorderState {
/// 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( _ChatRecorderState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChatRecorderState() 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( _ChatRecorderState value) $default,){
final _that = this;
switch (_that) {
case _ChatRecorderState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( _ChatRecorderState value)? $default,){
final _that = this;
switch (_that) {
case _ChatRecorderState() 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 isRecording, bool willCancel, double dragDx, Duration duration)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatRecorderState() when $default != null:
return $default(_that.isRecording,_that.willCancel,_that.dragDx,_that.duration);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 isRecording, bool willCancel, double dragDx, Duration duration) $default,) {final _that = this;
switch (_that) {
case _ChatRecorderState():
return $default(_that.isRecording,_that.willCancel,_that.dragDx,_that.duration);case _:
throw StateError('Unexpected subclass');
}
}
/// 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 isRecording, bool willCancel, double dragDx, Duration duration)? $default,) {final _that = this;
switch (_that) {
case _ChatRecorderState() when $default != null:
return $default(_that.isRecording,_that.willCancel,_that.dragDx,_that.duration);case _:
return null;
}
}
}
/// @nodoc
class _ChatRecorderState implements ChatRecorderState {
const _ChatRecorderState({this.isRecording = false, this.willCancel = false, this.dragDx = 0.0, this.duration = Duration.zero});
@override@JsonKey() final bool isRecording;
@override@JsonKey() final bool willCancel;
@override@JsonKey() final double dragDx;
@override@JsonKey() final Duration duration;
/// Create a copy of ChatRecorderState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChatRecorderStateCopyWith<_ChatRecorderState> get copyWith => __$ChatRecorderStateCopyWithImpl<_ChatRecorderState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatRecorderState&&(identical(other.isRecording, isRecording) || other.isRecording == isRecording)&&(identical(other.willCancel, willCancel) || other.willCancel == willCancel)&&(identical(other.dragDx, dragDx) || other.dragDx == dragDx)&&(identical(other.duration, duration) || other.duration == duration));
}
@override
int get hashCode => Object.hash(runtimeType,isRecording,willCancel,dragDx,duration);
@override
String toString() {
return 'ChatRecorderState(isRecording: $isRecording, willCancel: $willCancel, dragDx: $dragDx, duration: $duration)';
}
}
/// @nodoc
abstract mixin class _$ChatRecorderStateCopyWith<$Res> implements $ChatRecorderStateCopyWith<$Res> {
factory _$ChatRecorderStateCopyWith(_ChatRecorderState value, $Res Function(_ChatRecorderState) _then) = __$ChatRecorderStateCopyWithImpl;
@override @useResult
$Res call({
bool isRecording, bool willCancel, double dragDx, Duration duration
});
}
/// @nodoc
class __$ChatRecorderStateCopyWithImpl<$Res>
implements _$ChatRecorderStateCopyWith<$Res> {
__$ChatRecorderStateCopyWithImpl(this._self, this._then);
final _ChatRecorderState _self;
final $Res Function(_ChatRecorderState) _then;
/// Create a copy of ChatRecorderState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isRecording = null,Object? willCancel = null,Object? dragDx = null,Object? duration = null,}) {
return _then(_ChatRecorderState(
isRecording: null == isRecording ? _self.isRecording : isRecording // ignore: cast_nullable_to_non_nullable
as bool,willCancel: null == willCancel ? _self.willCancel : willCancel // ignore: cast_nullable_to_non_nullable
as bool,dragDx: null == dragDx ? _self.dragDx : dragDx // ignore: cast_nullable_to_non_nullable
as double,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,
));
}
}
// dart format on

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:navigation/navigation.dart';
import 'package:utils/utils.dart';
class ChatAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final IconData leadingIcon;
const ChatAppBar({
super.key,
required this.title,
required this.leadingIcon,
});
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final legacyPrimary = context.sfColors.legacyPrimary;
return AppBar(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: false,
leading: IconButton(
onPressed: () => GetIt.I<NavigationContract>().goBack(),
icon: Icon(
Icons.adaptive.arrow_back,
color: legacyPrimary,
size: SizeUtils.getByScreen(small: 32, big: 28),
),
),
titleSpacing: 0,
title: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: colorScheme.surfaceContainerHighest,
child: Icon(leadingIcon, color: legacyPrimary, size: 22),
),
const SizedBox(width: 10),
Flexible(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: SizeUtils.getByScreen<double>(small: 16, big: 15),
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:chat/src/core/domain/enums/chat_media_source.dart';
import 'package:flutter/material.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:sf_localizations/sf_localizations.dart';
class ChatAttachmentPickerSheet extends StatelessWidget {
const ChatAttachmentPickerSheet({super.key});
static Future<ChatMediaSource?> show(BuildContext context) {
return showModalBottomSheet<ChatMediaSource>(
context: context,
showDragHandle: true,
builder: (_) => const ChatAttachmentPickerSheet(),
);
}
@override
Widget build(BuildContext context) {
final legacyPrimary = context.sfColors.legacyPrimary;
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.photo_camera_outlined, color: legacyPrimary),
title: Text(context.translate(I18n.chatComposerCameraOption)),
onTap: () => Navigator.of(context).pop(ChatMediaSource.camera),
),
ListTile(
leading: Icon(Icons.photo_library_outlined, color: legacyPrimary),
title: Text(context.translate(I18n.chatComposerGalleryOption)),
onTap: () => Navigator.of(context).pop(ChatMediaSource.gallery),
),
const SizedBox(height: 8),
],
),
);
}
}

View File

@@ -0,0 +1,166 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_audio_player_controller.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_status_indicator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:utils/utils.dart';
class ChatAudioBubble extends ConsumerWidget {
final ChatMessageEntity message;
final VoidCallback? onRetry;
const ChatAudioBubble({super.key, required this.message, this.onRetry});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final bubbleColor = isOutgoing
? context.sfColors.legacyPrimary
: colorScheme.surfaceContainerHighest;
final textColor =
isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface;
final timeText = DateFormat.Hm().format(message.createdAt);
final isUploading =
message.isLocalOptimistic || message.uploadProgress < 1;
final initialDuration = Duration(milliseconds: message.fileDurationMs ?? 0);
final playerProvider = chatAudioPlayerControllerProvider(
message.id,
initialDuration: initialDuration,
);
final playerState = ref.watch(playerProvider);
final progress = playerState.total.inMilliseconds == 0
? 0.0
: playerState.position.inMilliseconds /
playerState.total.inMilliseconds;
return Align(
alignment: isOutgoing ? Alignment.centerRight : Alignment.centerLeft,
child: GestureDetector(
onTap: message.failed ? onRetry : null,
child: Container(
margin: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen<double>(small: 12, big: 12),
vertical: 4,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isOutgoing ? 16 : 4),
bottomRight: Radius.circular(isOutgoing ? 4 : 16),
),
),
child: Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!isOutgoing && message.userName != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
message.userName!,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: textColor.withValues(alpha: 0.7),
),
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isUploading)
SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
value: message.uploadProgress > 0
? message.uploadProgress
: null,
strokeWidth: 2,
color: textColor,
),
)
else
IconButton(
onPressed: () => ref
.read(playerProvider.notifier)
.toggle(
localFilePath: message.localFilePath,
fileId: message.content,
),
iconSize: 32,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
icon: Icon(
playerState.isPlaying
? Icons.pause_circle
: Icons.play_circle,
color: textColor,
),
),
const SizedBox(width: 8),
SizedBox(
width: 140,
child: LinearProgressIndicator(
value: progress.clamp(0, 1),
color: textColor,
backgroundColor: textColor.withValues(alpha: 0.3),
minHeight: 3,
),
),
const SizedBox(width: 8),
Text(
_format(playerState.isPlaying ||
playerState.position.inMilliseconds > 0
? playerState.position
: playerState.total),
style: TextStyle(fontSize: 12, color: textColor),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
timeText,
style: TextStyle(
fontSize: 10,
color: textColor.withValues(alpha: 0.7),
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
ChatStatusIndicator(
status: message.status,
failed: message.failed,
),
],
],
),
],
),
),
),
);
}
String _format(Duration d) {
final m = d.inMinutes.toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:sf_localizations/sf_localizations.dart';
class ChatDateSeparator extends StatelessWidget {
final DateTime date;
const ChatDateSeparator({super.key, required this.date});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDate = DateTime(date.year, date.month, date.day);
final difference = today.difference(messageDate).inDays;
final label = switch (difference) {
0 => context.translate(I18n.chatTodayLabel),
1 => context.translate(I18n.chatYesterdayLabel),
_ => DateFormat.yMMMMd().format(date),
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.outline,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,183 @@
import 'dart:io';
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/providers/chat_repository_provider.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_image_viewer_screen.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_status_indicator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:utils/utils.dart';
class ChatImageBubble extends ConsumerWidget {
final ChatMessageEntity message;
final VoidCallback? onRetry;
const ChatImageBubble({super.key, required this.message, this.onRetry});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final timeText = DateFormat.Hm().format(message.createdAt);
final repo = ref.read(chatRepositoryProvider);
final isUploading =
message.isLocalOptimistic || message.uploadProgress < 1;
final localPath = message.localFilePath;
final hasFileId = message.content.isNotEmpty && !message.isLocalOptimistic;
final networkUrl = hasFileId ? repo.fileUrlForId(message.content) : null;
return Align(
alignment: isOutgoing ? Alignment.centerRight : Alignment.centerLeft,
child: GestureDetector(
onTap: () {
if (message.failed) {
onRetry?.call();
} else {
ChatImageViewerScreen.show(
context,
networkUrl: networkUrl,
localPath: localPath,
);
}
},
child: Container(
margin: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen<double>(small: 12, big: 12),
vertical: 4,
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.7,
maxHeight: 280,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isOutgoing ? 16 : 4),
bottomRight: Radius.circular(isOutgoing ? 4 : 16),
),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
if (localPath != null && File(localPath).existsSync())
Image.file(
File(localPath),
fit: BoxFit.cover,
width: double.infinity,
height: 220,
)
else if (networkUrl != null)
Image.network(
networkUrl,
fit: BoxFit.cover,
width: double.infinity,
height: 220,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return SizedBox(
width: double.infinity,
height: 220,
child: Center(
child: CircularProgressIndicator(
value: progress.expectedTotalBytes != null
? progress.cumulativeBytesLoaded /
progress.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (_, __, ___) => const SizedBox(
height: 220,
child: Center(child: Icon(Icons.broken_image)),
),
),
if (isUploading && !message.failed)
Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.4),
child: Center(
child: CircularProgressIndicator(
value: message.uploadProgress > 0
? message.uploadProgress
: null,
color: Colors.white,
),
),
),
),
if (message.failed)
Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.5),
child: const Center(
child: Icon(Icons.error_outline,
color: Colors.white, size: 32),
),
),
),
Positioned(
right: 8,
bottom: 6,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
timeText,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
ChatStatusIndicator(
status: message.status,
failed: message.failed,
),
],
],
),
),
),
if (!isOutgoing && message.userName != null)
Positioned(
left: 8,
top: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Text(
message.userName!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class ChatImageViewerScreen extends StatelessWidget {
final String? networkUrl;
final String? localPath;
const ChatImageViewerScreen({super.key, this.networkUrl, this.localPath});
static Future<void> show(
BuildContext context, {
String? networkUrl,
String? localPath,
}) {
return Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => ChatImageViewerScreen(
networkUrl: networkUrl,
localPath: localPath,
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
iconTheme: const IconThemeData(color: Colors.white),
),
body: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Center(
child: InteractiveViewer(
child: localPath != null
? Image.file(File(localPath!))
: networkUrl != null
? FutureBuilder<File>(
future: DefaultCacheManager().getSingleFile(
networkUrl!,
),
builder: (context, snapshot) {
final file = snapshot.data;
if (file == null) {
return const CircularProgressIndicator();
}
return Image.file(file);
},
)
: const SizedBox.shrink(),
),
),
),
);
}
}

View File

@@ -0,0 +1,306 @@
import 'dart:async';
import 'package:chat/src/core/domain/enums/chat_media_source.dart';
import 'package:chat/src/core/domain/services/chat_permissions.dart';
import 'package:chat/src/core/providers/chat_permissions_provider.dart';
import 'package:chat/src/features/chat_conversation/presentation/providers/chat_recorder_controller.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_attachment_picker_sheet.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_recording_overlay.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:sf_tracking/sf_tracking.dart';
import 'package:utils/utils.dart';
class ChatInputBar extends ConsumerStatefulWidget {
final bool isSending;
final Future<bool> Function(String content) onSendText;
final Future<bool> Function(String filePath, ChatMediaSource source)
onSendImage;
final Future<bool> Function({required String filePath, required int durationMs})
onSendAudio;
const ChatInputBar({
super.key,
required this.isSending,
required this.onSendText,
required this.onSendImage,
required this.onSendAudio,
});
@override
ConsumerState<ChatInputBar> createState() => _ChatInputBarState();
}
class _ChatInputBarState extends ConsumerState<ChatInputBar> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
bool _waitingForMicPermission = false;
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _handleSendText() async {
final text = _controller.text.trim();
if (text.isEmpty || widget.isSending) return;
final ok = await widget.onSendText(text);
if (ok && mounted) {
_controller.clear();
_focusNode.requestFocus();
}
}
Future<void> _handleAttachTap() async {
final source = await ChatAttachmentPickerSheet.show(context);
if (source == null || !mounted) return;
await _pickAndSendImage(source);
}
Future<void> _pickAndSendImage(ChatMediaSource source) async {
final permissions = ref.read(chatPermissionsProvider);
final result = source == ChatMediaSource.camera
? await permissions.ensureCamera()
: await permissions.ensurePhotos();
if (!mounted) return;
if (result != ChatPermissionResult.granted) {
_handlePermissionDenied(
permission: source == ChatMediaSource.camera ? 'camera' : 'photos',
result: result,
deniedKey: source == ChatMediaSource.camera
? I18n.chatPermissionCameraDenied
: I18n.chatPermissionPhotosDenied,
);
return;
}
try {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: source == ChatMediaSource.camera
? ImageSource.camera
: ImageSource.gallery,
maxWidth: 4096,
);
if (picked == null) return;
await widget.onSendImage(picked.path, source);
} catch (_) {
if (mounted) {
await showErrorDialog(context, I18n.errorChatImagePickFailed);
}
}
}
Future<void> _startRecording() async {
final permissions = ref.read(chatPermissionsProvider);
_waitingForMicPermission = true;
final result = await permissions.ensureMicrophone();
if (!mounted) return;
if (result != ChatPermissionResult.granted) {
_handlePermissionDenied(
permission: 'microphone',
result: result,
deniedKey: I18n.chatPermissionMicrophoneDenied,
);
return;
}
if (!_waitingForMicPermission) return;
_waitingForMicPermission = false;
final ok =
await ref.read(chatRecorderControllerProvider.notifier).start();
if (!ok && mounted) {
await showErrorDialog(context, I18n.errorChatAudioRecordingFailed);
}
}
Future<void> _stopAndSendRecording() async {
if (_waitingForMicPermission) {
_waitingForMicPermission = false;
return;
}
final notifier = ref.read(chatRecorderControllerProvider.notifier);
if (!ref.read(chatRecorderControllerProvider).isRecording) return;
final result = await notifier.stop();
if (result.path == null || result.wasCancelled || result.durationMs < 1000) {
return;
}
await widget.onSendAudio(
filePath: result.path!,
durationMs: result.durationMs,
);
}
Future<void> _cancelRecording() async {
_waitingForMicPermission = false;
if (!ref.read(chatRecorderControllerProvider).isRecording) return;
await ref.read(chatRecorderControllerProvider.notifier).cancel();
}
void _updateCancelHint(LongPressMoveUpdateDetails details) {
ref
.read(chatRecorderControllerProvider.notifier)
.updateDrag(details.localOffsetFromOrigin.dx);
}
Future<void> _handlePermissionDenied({
required String permission,
required ChatPermissionResult result,
required String deniedKey,
}) async {
final permanently = result == ChatPermissionResult.permanentlyDenied;
unawaited(
ref.read(sfTrackingProvider).legacyChatPermissionDenied(
permission: permission,
permanently: permanently,
),
);
if (!permanently) {
await showErrorDialog(context, deniedKey);
return;
}
final permissions = ref.read(chatPermissionsProvider);
final shouldOpen = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
content: Text(context.translate(deniedKey)),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(context.translate(I18n.cancel)),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(context.translate(I18n.chatPermissionOpenSettings)),
),
],
),
);
if (shouldOpen == true) {
await permissions.openSettings();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final recorder = ref.watch(chatRecorderControllerProvider);
return Material(
color: colorScheme.surface,
elevation: 4,
child: SafeArea(
top: false,
child: Stack(
children: [
_buildComposer(context),
if (recorder.isRecording)
Positioned.fill(
child: ChatRecordingOverlay(
duration: recorder.duration,
willCancel: recorder.willCancel,
dragDx: recorder.dragDx,
),
),
],
),
),
);
}
Widget _buildComposer(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen<double>(small: 8, big: 8),
vertical: SizeUtils.getByScreen<double>(small: 8, big: 8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
onPressed: widget.isSending ? null : _handleAttachTap,
icon: Icon(
Icons.add_circle_outline,
color: context.sfColors.legacyPrimary,
),
tooltip: context.translate(I18n.chatComposerAttachTooltip),
),
Expanded(
child: TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: 5,
minLines: 1,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText: context.translate(I18n.chatComposerHint),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
const SizedBox(width: 4),
ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final hasText = _controller.text.trim().isNotEmpty;
final canSend = hasText && !widget.isSending;
if (hasText || widget.isSending) {
return IconButton(
onPressed: canSend ? _handleSendText : null,
icon: widget.isSending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
Icons.send_rounded,
color: canSend
? context.sfColors.legacyPrimary
: Theme.of(context).colorScheme.outline,
),
tooltip: context.translate(I18n.chatComposerSendTooltip),
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showInfoDialog(
context,
I18n.chatComposerRecordHoldHint,
),
onLongPress: _startRecording,
onLongPressMoveUpdate: _updateCancelHint,
onLongPressEnd: (_) => _stopAndSendRecording(),
onLongPressCancel: _cancelRecording,
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
Icons.mic_outlined,
color: context.sfColors.legacyPrimary,
size: 24,
),
),
);
},
),
],
),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_audio_bubble.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_image_bubble.dart';
import 'package:chat/src/features/chat_conversation/presentation/widgets/chat_status_indicator.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:utils/utils.dart';
class ChatMessageBubble extends StatelessWidget {
final ChatMessageEntity message;
final VoidCallback? onRetry;
const ChatMessageBubble({super.key, required this.message, this.onRetry});
@override
Widget build(BuildContext context) {
return switch (message.type) {
ChatMessageType.text || ChatMessageType.emoji => _TextBubble(
message: message,
onRetry: onRetry,
),
ChatMessageType.image => ChatImageBubble(
message: message,
onRetry: onRetry,
),
ChatMessageType.audio => ChatAudioBubble(
message: message,
onRetry: onRetry,
),
};
}
}
class _TextBubble extends StatelessWidget {
final ChatMessageEntity message;
final VoidCallback? onRetry;
const _TextBubble({required this.message, this.onRetry});
@override
Widget build(BuildContext context) {
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final bubbleColor = isOutgoing
? context.sfColors.legacyPrimary
: colorScheme.surfaceContainerHighest;
final textColor = isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface;
final timeText = DateFormat.Hm().format(message.createdAt);
return Align(
alignment: isOutgoing ? Alignment.centerRight : Alignment.centerLeft,
child: GestureDetector(
onTap: message.failed ? onRetry : null,
child: Container(
margin: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen<double>(small: 12, big: 12),
vertical: 4,
),
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen<double>(small: 14, big: 14),
vertical: SizeUtils.getByScreen<double>(small: 10, big: 9),
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isOutgoing ? 16 : 4),
bottomRight: Radius.circular(isOutgoing ? 4 : 16),
),
),
child: Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (!isOutgoing && message.userName != null)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
message.userName!,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: textColor.withValues(alpha: 0.7),
),
),
),
Text(
message.content,
style: TextStyle(
fontSize: SizeUtils.getByScreen<double>(small: 15, big: 14),
color: textColor,
),
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
timeText,
style: TextStyle(
fontSize: 10,
color: textColor.withValues(alpha: 0.7),
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
ChatStatusIndicator(
status: message.status,
failed: message.failed,
),
],
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:sf_localizations/sf_localizations.dart';
class ChatRecordingOverlay extends StatelessWidget {
final Duration duration;
final bool willCancel;
final double dragDx;
const ChatRecordingOverlay({
super.key,
required this.duration,
required this.willCancel,
this.dragDx = 0,
});
String _format(Duration d) {
final minutes = d.inMinutes.toString().padLeft(2, '0');
final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final accent = Colors.red.shade600;
final accentStrong = Colors.red.shade800;
final hintOpacity = (1.0 - dragDx.abs() / 60).clamp(0.0, 1.0);
return Material(
color: colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
_PulsingDot(color: accent),
const SizedBox(width: 12),
Text(
_format(duration),
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
color: colorScheme.onSurface,
),
),
const Spacer(),
_CancelHint(
willCancel: willCancel,
hintOpacity: hintOpacity,
dragDx: dragDx,
accent: accentStrong,
outline: colorScheme.outline,
),
const SizedBox(width: 16),
Transform.translate(
offset: Offset(dragDx * 0.6, 0),
child: AnimatedScale(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
scale: willCancel ? 1.15 : 1.0,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 44,
height: 44,
decoration: BoxDecoration(
color: willCancel ? accentStrong : accent,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (willCancel ? accentStrong : accent)
.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.mic,
color: Colors.white,
size: 22,
),
),
),
),
],
),
),
);
}
}
class _CancelHint extends StatelessWidget {
final bool willCancel;
final double hintOpacity;
final double dragDx;
final Color accent;
final Color outline;
const _CancelHint({
required this.willCancel,
required this.hintOpacity,
required this.dragDx,
required this.accent,
required this.outline,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.2, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: willCancel
? Text(
context.translate(I18n.chatRecordingReleaseToCancel),
key: const ValueKey('cancel'),
style: TextStyle(
fontSize: 13,
color: accent,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
)
: Opacity(
key: const ValueKey('hint'),
opacity: hintOpacity,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Transform.translate(
offset: Offset(dragDx * 0.3, 0),
child: Icon(
Icons.chevron_left_rounded,
size: 18,
color: outline,
),
),
Text(
context.translate(I18n.chatRecordingCancelHint),
style: TextStyle(
fontSize: 13,
color: outline,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}
class _PulsingDot extends StatefulWidget {
final Color color;
const _PulsingDot({required this.color});
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (_, __) {
final t = _controller.value;
return SizedBox(
width: 14,
height: 14,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 14 + 6 * t,
height: 14 + 6 * t,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color.withValues(alpha: 0.3 * (1 - t)),
),
),
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
import 'package:flutter/material.dart';
import 'package:legacy_theme/legacy_theme.dart';
class ChatStatusIndicator extends StatelessWidget {
final ChatMessageStatus status;
final bool failed;
const ChatStatusIndicator({
super.key,
required this.status,
required this.failed,
});
@override
Widget build(BuildContext context) {
final color = failed
? Theme.of(context).colorScheme.error
: context.sfColors.legacyPrimary;
final icon = failed
? Icons.error_outline
: switch (status) {
ChatMessageStatus.wait => Icons.schedule,
ChatMessageStatus.send => Icons.check,
ChatMessageStatus.delivered => Icons.done_all,
};
return Icon(icon, size: 14, color: color);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:chat/src/features/chat_list/presentation/chat_list_screen.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class ChatListBuilder {
const ChatListBuilder();
Page<void> buildPage(BuildContext context, GoRouterState state) =>
MaterialPage<void>(key: state.pageKey, child: const ChatListScreen());
}

View File

@@ -0,0 +1,108 @@
import 'package:chat/src/features/chat_list/presentation/providers/chat_list_controller.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:legacy_ui/legacy_ui.dart';
import 'package:navigation/navigation.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:utils/utils.dart';
class ChatListScreen extends ConsumerWidget {
const ChatListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(chatListControllerProvider);
return LegacyPageLayout(
title: context.translate(I18n.chatScreenTitle),
showBack: false,
body: state.when(
loading: () => const LegacyLoadingIndicator(),
error: (_, __) => RefreshableErrorState(
onRefresh: () async => ref.invalidate(chatListControllerProvider),
),
data: (data) => _ChatListBody(
oneToOneChatId: data.oneToOne?.chatId,
oneToOneDisplayName: data.oneToOne?.displayName ?? '',
familyGroupChatId: data.familyGroup?.chatId,
),
),
);
}
}
class _ChatListBody extends StatelessWidget {
final String? oneToOneChatId;
final String oneToOneDisplayName;
final String? familyGroupChatId;
const _ChatListBody({
required this.oneToOneChatId,
required this.oneToOneDisplayName,
required this.familyGroupChatId,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final horizontalPadding = SizeUtils.getByScreen<double>(big: 22, small: 21);
return Padding(
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
if (oneToOneChatId != null) ...[
Text(
oneToOneDisplayName,
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 14, big: 13),
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 12),
LegacyOptionCard(
image: Image.asset(
'assets/shared/images/iso_sf.png',
width: SizeUtils.getByScreen<double>(small: 48, big: 44),
height: SizeUtils.getByScreen<double>(small: 48, big: 44),
),
text: oneToOneDisplayName,
onTap: () => context.go(
AppRoutes.legacyChatConversationFor(oneToOneChatId!),
),
),
const SizedBox(height: 12),
],
if (familyGroupChatId != null)
LegacyOptionCard(
icon: Icon(
Icons.groups_outlined,
size: SizeUtils.getByScreen<double>(small: 36, big: 32),
color: context.sfColors.legacyPrimary,
),
text: context.translate(I18n.chatFamilyGroupTitle),
onTap: () => context.go(
AppRoutes.legacyChatConversationFor(familyGroupChatId!),
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
context.translate(I18n.chatFamilyDisclaimer),
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 12, big: 11),
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:chat/src/core/domain/entities/chat_conversation_entity.dart';
import 'package:chat/src/core/providers/chat_id_resolver_provider.dart';
import 'package:chat/src/core/providers/family_chat_provider.dart';
import 'package:chat/src/features/chat_list/presentation/providers/chat_list_state.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:sf_shared/sf_shared.dart';
part 'chat_list_controller.g.dart';
@Riverpod(keepAlive: true)
class ChatListController extends _$ChatListController {
@override
Future<ChatListState> build() async {
final user = await ref.watch(userInfoProvider.future);
final selectedDevice = ref.watch(selectedDeviceProvider).value;
if (selectedDevice == null) {
return const ChatListState();
}
final resolver = ref.read(chatIdResolverProvider);
final oneToOneChatId = resolver.oneToOneChatId(
userId: user.id,
deviceIdentificator: selectedDevice.identificator,
);
final oneToOne = OneToOneConversation(
chatId: oneToOneChatId,
deviceId: selectedDevice.id,
deviceIdentificator: selectedDevice.identificator,
displayName: selectedDevice.carrierName ?? selectedDevice.identificator,
avatarBackgroundImageId: selectedDevice.backgroundImageId,
);
final familyChatId = ref.watch(familyChatIdProvider);
final familyMembers = ref.watch(familyChatMembersProvider);
final familyGroup = familyChatId == null
? null
: FamilyGroupConversation(
chatId: familyChatId,
displayName: '',
memberIdentificators: familyMembers
.map((d) => d.identificator)
.toList(),
);
return ChatListState(oneToOne: oneToOne, familyGroup: familyGroup);
}
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'chat_list_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ChatListController)
const chatListControllerProvider = ChatListControllerProvider._();
final class ChatListControllerProvider
extends $AsyncNotifierProvider<ChatListController, ChatListState> {
const ChatListControllerProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'chatListControllerProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$chatListControllerHash();
@$internal
@override
ChatListController create() => ChatListController();
}
String _$chatListControllerHash() =>
r'38f1400d0e1bc89af86ef16a247726b9b3deeb4b';
abstract class _$ChatListController extends $AsyncNotifier<ChatListState> {
FutureOr<ChatListState> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<ChatListState>, ChatListState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<ChatListState>, ChatListState>,
AsyncValue<ChatListState>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:chat/src/core/domain/entities/chat_conversation_entity.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_list_state.freezed.dart';
@freezed
abstract class ChatListState with _$ChatListState {
const factory ChatListState({
OneToOneConversation? oneToOne,
FamilyGroupConversation? familyGroup,
}) = _ChatListState;
}

View File

@@ -0,0 +1,274 @@
// 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_list_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ChatListState {
OneToOneConversation? get oneToOne; FamilyGroupConversation? get familyGroup;
/// Create a copy of ChatListState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ChatListStateCopyWith<ChatListState> get copyWith => _$ChatListStateCopyWithImpl<ChatListState>(this as ChatListState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatListState&&const DeepCollectionEquality().equals(other.oneToOne, oneToOne)&&const DeepCollectionEquality().equals(other.familyGroup, familyGroup));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(oneToOne),const DeepCollectionEquality().hash(familyGroup));
@override
String toString() {
return 'ChatListState(oneToOne: $oneToOne, familyGroup: $familyGroup)';
}
}
/// @nodoc
abstract mixin class $ChatListStateCopyWith<$Res> {
factory $ChatListStateCopyWith(ChatListState value, $Res Function(ChatListState) _then) = _$ChatListStateCopyWithImpl;
@useResult
$Res call({
OneToOneConversation? oneToOne, FamilyGroupConversation? familyGroup
});
}
/// @nodoc
class _$ChatListStateCopyWithImpl<$Res>
implements $ChatListStateCopyWith<$Res> {
_$ChatListStateCopyWithImpl(this._self, this._then);
final ChatListState _self;
final $Res Function(ChatListState) _then;
/// Create a copy of ChatListState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? oneToOne = freezed,Object? familyGroup = freezed,}) {
return _then(_self.copyWith(
oneToOne: freezed == oneToOne ? _self.oneToOne : oneToOne // ignore: cast_nullable_to_non_nullable
as OneToOneConversation?,familyGroup: freezed == familyGroup ? _self.familyGroup : familyGroup // ignore: cast_nullable_to_non_nullable
as FamilyGroupConversation?,
));
}
}
/// Adds pattern-matching-related methods to [ChatListState].
extension ChatListStatePatterns on ChatListState {
/// 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( _ChatListState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _ChatListState() 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( _ChatListState value) $default,){
final _that = this;
switch (_that) {
case _ChatListState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( _ChatListState value)? $default,){
final _that = this;
switch (_that) {
case _ChatListState() 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( OneToOneConversation? oneToOne, FamilyGroupConversation? familyGroup)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _ChatListState() when $default != null:
return $default(_that.oneToOne,_that.familyGroup);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( OneToOneConversation? oneToOne, FamilyGroupConversation? familyGroup) $default,) {final _that = this;
switch (_that) {
case _ChatListState():
return $default(_that.oneToOne,_that.familyGroup);case _:
throw StateError('Unexpected subclass');
}
}
/// 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( OneToOneConversation? oneToOne, FamilyGroupConversation? familyGroup)? $default,) {final _that = this;
switch (_that) {
case _ChatListState() when $default != null:
return $default(_that.oneToOne,_that.familyGroup);case _:
return null;
}
}
}
/// @nodoc
class _ChatListState implements ChatListState {
const _ChatListState({this.oneToOne, this.familyGroup});
@override final OneToOneConversation? oneToOne;
@override final FamilyGroupConversation? familyGroup;
/// Create a copy of ChatListState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ChatListStateCopyWith<_ChatListState> get copyWith => __$ChatListStateCopyWithImpl<_ChatListState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatListState&&const DeepCollectionEquality().equals(other.oneToOne, oneToOne)&&const DeepCollectionEquality().equals(other.familyGroup, familyGroup));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(oneToOne),const DeepCollectionEquality().hash(familyGroup));
@override
String toString() {
return 'ChatListState(oneToOne: $oneToOne, familyGroup: $familyGroup)';
}
}
/// @nodoc
abstract mixin class _$ChatListStateCopyWith<$Res> implements $ChatListStateCopyWith<$Res> {
factory _$ChatListStateCopyWith(_ChatListState value, $Res Function(_ChatListState) _then) = __$ChatListStateCopyWithImpl;
@override @useResult
$Res call({
OneToOneConversation? oneToOne, FamilyGroupConversation? familyGroup
});
}
/// @nodoc
class __$ChatListStateCopyWithImpl<$Res>
implements _$ChatListStateCopyWith<$Res> {
__$ChatListStateCopyWithImpl(this._self, this._then);
final _ChatListState _self;
final $Res Function(_ChatListState) _then;
/// Create a copy of ChatListState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? oneToOne = freezed,Object? familyGroup = freezed,}) {
return _then(_ChatListState(
oneToOne: freezed == oneToOne ? _self.oneToOne : oneToOne // ignore: cast_nullable_to_non_nullable
as OneToOneConversation?,familyGroup: freezed == familyGroup ? _self.familyGroup : familyGroup // ignore: cast_nullable_to_non_nullable
as FamilyGroupConversation?,
));
}
}
// dart format on

View File

@@ -0,0 +1,64 @@
name: chat
description: "Chat 1:1 and family group module for legacy dashboard"
publish_to: 'none'
resolution: workspace
version: 1.0.0+1
environment:
sdk: ^3.9.2
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
legacy_device_state:
path: ../../packages/legacy_device_state
legacy_theme:
path: ../../packages/legacy_theme
legacy_ui:
path: ../../packages/legacy_ui
design_system:
path: ../../../../packages/design_system
navigation:
path: ../../../../packages/navigation
sf_infrastructure:
path: ../../../../packages/sf_infrastructure
sf_localizations:
path: ../../../../packages/sf_localizations
sf_shared:
path: ../../../../packages/sf_shared
sf_tracking:
path: ../../../../packages/sf_tracking
utils:
path: ../../../../packages/utils
get_it: ^9.0.5
flutter_riverpod: ^3.0.3
riverpod_annotation: ^3.0.3
go_router: ^17.0.0
freezed_annotation: ^3.1.0
json_annotation: ^4.9.0
dio: ^5.9.2
uuid: ^4.5.3
intl: ^0.20.2
http_parser: ^4.1.2
image_picker: ^1.2.1
flutter_image_compress: ^2.4.0
record: ^6.0.0
audioplayers: ^6.1.0
path_provider: ^2.1.5
flutter_cache_manager: ^3.4.1
permission_handler: ^12.0.1
shared_preferences: ^2.5.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
build_runner: ^2.7.1
freezed: ^3.2.3
json_serializable: ^6.11.2
riverpod_generator: ^3.0.3
mocktail: ^1.0.4
flutter:
uses-material-design: true

View File

@@ -0,0 +1,194 @@
import 'package:chat/src/core/data/datasource/chat_remote_datasource.dart';
import 'package:chat/src/core/data/repositories/chat_repository_impl.dart';
import 'package:chat/src/core/data/utils/chat_file_url_builder.dart';
import 'package:chat/src/core/domain/entities/chat_message_entity.dart';
import 'package:chat/src/core/domain/enums/chat_message_status.dart';
import 'package:chat/src/core/domain/enums/chat_message_type.dart';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
class _MockChatRemoteDatasource extends Mock implements ChatRemoteDatasource {}
void main() {
late _MockChatRemoteDatasource remote;
late ChatRepositoryImpl repository;
final sampleMessage = ChatMessageEntity(
id: 'm1',
deviceIdentificator: 'd1',
type: ChatMessageType.text,
content: 'hi',
status: ChatMessageStatus.delivered,
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
setUpAll(() {
registerFallbackValue(ChatMessageType.text);
});
setUp(() {
remote = _MockChatRemoteDatasource();
repository = ChatRepositoryImpl(
remote,
const ChatFileUrlBuilder('https://api.example.com'),
);
});
group('listMessages', () {
test('returns the remote response on success', () async {
when(
() => remote.listMessages(
deviceIdentificator: any(named: 'deviceIdentificator'),
chatId: any(named: 'chatId'),
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
),
).thenAnswer((_) async => [sampleMessage]);
final result = await repository.listMessages(deviceIdentificator: 'd1');
expect(result, [sampleMessage]);
});
test('returns empty list on 404', () async {
when(
() => remote.listMessages(
deviceIdentificator: any(named: 'deviceIdentificator'),
chatId: any(named: 'chatId'),
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
),
).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/'),
response: Response(
requestOptions: RequestOptions(path: '/'),
statusCode: 404,
),
),
);
final result = await repository.listMessages(deviceIdentificator: 'd1');
expect(result, isEmpty);
});
test('throws ApiException on other DioException', () async {
when(
() => remote.listMessages(
deviceIdentificator: any(named: 'deviceIdentificator'),
chatId: any(named: 'chatId'),
page: any(named: 'page'),
pageSize: any(named: 'pageSize'),
),
).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/'),
response: Response(
requestOptions: RequestOptions(path: '/'),
statusCode: 500,
),
),
);
expect(
() => repository.listMessages(deviceIdentificator: 'd1'),
throwsA(isA<ApiException>()),
);
});
});
group('sendTextMessage', () {
test('forwards args to the remote and returns its result', () async {
when(
() => remote.sendTextMessage(
id: any(named: 'id'),
deviceIdentificator: any(named: 'deviceIdentificator'),
chatId: any(named: 'chatId'),
type: any(named: 'type'),
content: any(named: 'content'),
userId: any(named: 'userId'),
userName: any(named: 'userName'),
),
).thenAnswer((_) async => sampleMessage);
final result = await repository.sendTextMessage(
id: 'm1',
deviceIdentificator: 'd1',
chatId: 'c1',
type: ChatMessageType.text,
content: 'hi',
userId: 'u1',
userName: 'Ada',
);
expect(result, sampleMessage);
});
test('wraps DioException in ApiException', () async {
when(
() => remote.sendTextMessage(
id: any(named: 'id'),
deviceIdentificator: any(named: 'deviceIdentificator'),
chatId: any(named: 'chatId'),
type: any(named: 'type'),
content: any(named: 'content'),
userId: any(named: 'userId'),
userName: any(named: 'userName'),
),
).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/'),
response: Response(
requestOptions: RequestOptions(path: '/'),
statusCode: 403,
),
),
);
expect(
() => repository.sendTextMessage(
id: 'm1',
deviceIdentificator: 'd1',
chatId: 'c1',
type: ChatMessageType.text,
content: 'hi',
userId: 'u1',
userName: 'Ada',
),
throwsA(isA<ApiException>()),
);
});
});
test('sendAudioMessage attaches durationMs to the returned entity', () async {
when(
() => remote.sendAudioMessage(
id: any(named: 'id'),
deviceIdentificator: any(named: 'deviceIdentificator'),
chatId: any(named: 'chatId'),
filePath: any(named: 'filePath'),
userId: any(named: 'userId'),
userName: any(named: 'userName'),
onProgress: any(named: 'onProgress'),
),
).thenAnswer((_) async => sampleMessage);
final result = await repository.sendAudioMessage(
id: 'm1',
deviceIdentificator: 'd1',
chatId: 'c1',
filePath: '/tmp/a.m4a',
durationMs: 1234,
userId: 'u1',
userName: 'Ada',
);
expect(result.fileDurationMs, 1234);
});
test('fileUrlForId delegates to ChatFileUrlBuilder', () {
expect(
repository.fileUrlForId('xyz'),
'https://api.example.com/devices/static-files/xyz',
);
});
}

View File

@@ -0,0 +1,22 @@
import 'package:chat/src/core/data/utils/chat_file_url_builder.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('ChatFileUrlBuilder', () {
test('builds url joining base and fileId', () {
const builder = ChatFileUrlBuilder('https://api.example.com');
expect(
builder.urlForFileId('abc-123'),
'https://api.example.com/devices/static-files/abc-123',
);
});
test('strips trailing slash from base url', () {
const builder = ChatFileUrlBuilder('https://api.example.com/');
expect(
builder.urlForFileId('xyz'),
'https://api.example.com/devices/static-files/xyz',
);
});
});
}

View File

@@ -0,0 +1,45 @@
import 'package:chat/src/core/domain/services/chat_id_resolver.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const resolver = ChatIdResolver();
group('ChatIdResolver', () {
test('oneToOneChatId concatenates user and device', () {
final chatId = resolver.oneToOneChatId(
userId: 'user-123',
deviceIdentificator: 'device-abc',
);
expect(chatId, '1to1_user-123_device-abc');
});
test('familyChatId uses delegationId when present', () {
final chatId = resolver.familyChatId(
userId: 'user-123',
delegationId: 'delegation-xyz',
);
expect(chatId, 'family_delegation-xyz');
});
test('familyChatId falls back to userId when delegationId is null', () {
final chatId = resolver.familyChatId(userId: 'user-123');
expect(chatId, 'family_user-123');
});
test('isOneToOne matches the 1to1 prefix only', () {
expect(resolver.isOneToOne('1to1_a_b'), isTrue);
expect(resolver.isOneToOne('family_a'), isFalse);
expect(resolver.isOneToOne('random'), isFalse);
});
test('isFamilyGroup matches the family prefix only', () {
expect(resolver.isFamilyGroup('family_a'), isTrue);
expect(resolver.isFamilyGroup('1to1_a_b'), isFalse);
});
test('newChatMessageId returns a v4 uuid', () {
final id = resolver.newChatMessageId();
expect(id, matches(RegExp(r'^[0-9a-f-]{36}$')));
});
});
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_svg/svg.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:legacy_device_state/legacy_device_state.dart';
@@ -236,8 +238,6 @@ class _MapSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final vm = ref.read(legacyDeviceViewModelProvider.notifier);
return GestureDetector(
onTap: () => navigationContract.goTo(AppRoutes.legacyLocation),
child: Column(
@@ -256,11 +256,33 @@ class _MapSection extends ConsumerWidget {
),
IconButton(
onPressed: () async {
if (!await guardDeviceCommand(context, ref)) return;
final device = ref
.read(legacyDeviceViewModelProvider)
.value
?.selectedDevice;
if (device == null) return;
try {
await vm.refreshPositions();
await showInfoDialog(
context,
I18n.positionUpdating,
autoDismiss: const Duration(seconds: 60),
);
final position = await ref
.read(legacyDeviceViewModelProvider.notifier)
.requestPositionUpdate(
deviceIdentificator: device.identificator,
);
if (!context.mounted) return;
await showSuccessDialog(context, I18n.positionUpdated);
} catch (e) {
if (position != null) {
await showSuccessDialog(context, I18n.positionUpdated);
} else {
await showErrorDialog(context, I18n.errorPositions);
}
} on TimeoutException {
if (!context.mounted) return;
await showErrorDialog(context, I18n.positionUpdateTimeout);
} catch (_) {
if (!context.mounted) return;
await showErrorDialog(context, I18n.errorPositions);
}

View File

@@ -122,7 +122,7 @@ class _IdleBody extends StatelessWidget {
),
),
const SizedBox(height: 12),
_VideocallOptionCard(
LegacyOptionCard(
image: Image.asset(
'assets/shared/images/iso_sf.png',
width: SizeUtils.getByScreen<double>(small: 48, big: 44),
@@ -132,7 +132,7 @@ class _IdleBody extends StatelessWidget {
onTap: () => vm.startCall(''),
),
const SizedBox(height: 12),
_VideocallOptionCard(
LegacyOptionCard(
icon: Icon(
Icons.groups_outlined,
size: SizeUtils.getByScreen<double>(small: 36, big: 32),
@@ -155,62 +155,6 @@ class _IdleBody extends StatelessWidget {
}
}
class _VideocallOptionCard extends StatelessWidget {
const _VideocallOptionCard({
this.image,
this.icon,
required this.text,
required this.onTap,
});
final Widget? image;
final Widget? icon;
final String text;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(12),
elevation: 0.5,
shadowColor: colorScheme.shadow.withValues(alpha: 0.3),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen<double>(small: 16, big: 15),
vertical: SizeUtils.getByScreen<double>(small: 14, big: 13),
),
child: Row(
children: [
SizedBox(
width: SizeUtils.getByScreen<double>(small: 48, big: 44),
height: SizeUtils.getByScreen<double>(small: 48, big: 44),
child: Center(child: image ?? icon),
),
SizedBox(width: SizeUtils.getByScreen<double>(small: 16, big: 15)),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 16, big: 15),
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
),
],
),
),
),
);
}
}
class _ActiveCallView extends StatelessWidget {
const _ActiveCallView({
required this.state,

View File

@@ -1,3 +1,4 @@
export 'src/components/legacy_option_card.dart';
export 'src/components/menu_button.dart';
export 'src/components/section_button.dart';
export 'src/widgets/layouts/page_layout.dart';

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:utils/utils.dart';
class LegacyOptionCard extends StatelessWidget {
const LegacyOptionCard({
super.key,
this.image,
this.icon,
required this.text,
required this.onTap,
});
final Widget? image;
final Widget? icon;
final String text;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(12),
elevation: 0.5,
shadowColor: colorScheme.shadow.withValues(alpha: 0.3),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: SizeUtils.getByScreen<double>(small: 16, big: 15),
vertical: SizeUtils.getByScreen<double>(small: 14, big: 13),
),
child: Row(
children: [
SizedBox(
width: SizeUtils.getByScreen<double>(small: 48, big: 44),
height: SizeUtils.getByScreen<double>(small: 48, big: 44),
child: Center(child: image ?? icon),
),
SizedBox(width: SizeUtils.getByScreen<double>(small: 16, big: 15)),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: SizeUtils.getByScreen(small: 16, big: 15),
fontWeight: FontWeight.w500,
color: colorScheme.onSurface,
),
),
),
],
),
),
),
);
}
}

View File

@@ -58,6 +58,11 @@ class AppRoutes {
static const deviceManagement = '$legacyDashboard/device_management';
static const legacyLocation = '$legacyDashboard/location';
static const legacyChat = '$legacyDashboard/chat';
static const legacyChatConversation = '$legacyChat/conversation/:chatId';
static String legacyChatConversationFor(String chatId) =>
'$legacyChat/conversation/$chatId';
static const scheduledActivities = '$deviceManagement/scheduled_activities';
static const contacts = '$deviceManagement/contacts';
static const editContact = '$contacts/edit/:contactId';

View File

@@ -179,6 +179,21 @@ class VideoCallRequestResponseEvent extends WebSocketEvent {
bool get isOk => status == 'ok';
}
class ChatMessageEvent extends WebSocketEvent {
final String messageId;
final String? chatId;
final String deviceIdentificator;
final String status;
const ChatMessageEvent({
required this.messageId,
required this.chatId,
required this.deviceIdentificator,
required this.status,
required super.timestamp,
}) : super(type: 'chat_message');
}
class PositionUpdateEvent extends WebSocketEvent {
final String deviceIdentificator;
final String positionId;

View File

@@ -48,6 +48,19 @@ WebSocketEvent? parseWebSocketEvent(String raw) {
);
}
if (type == 'chat_message') {
return ChatMessageEvent(
messageId: json['messageId'] as String? ?? data['messageId'] as String? ?? '',
chatId: json['chatId'] as String? ?? data['chatId'] as String?,
deviceIdentificator:
json['deviceIdentificator'] as String? ??
data['deviceIdentificator'] as String? ??
'',
status: json['status'] as String? ?? data['status'] as String? ?? '',
timestamp: timestamp,
);
}
if (type == 'position_processed') {
return PositionUpdateEvent(
deviceIdentificator: json['deviceIdentificator'] as String? ?? '',

View File

@@ -1108,5 +1108,35 @@
"errorValidation": "Die eingegebenen Daten sind ungültig.",
"errorScaRequired": "Eine zusätzliche Authentifizierung ist erforderlich.",
"errorDeviceNotOwned": "Du hast keine Berechtigung, auf dieses Gerät zuzugreifen.",
"errorGeneric": "Ein unerwarteter Fehler ist aufgetreten."
"errorGeneric": "Ein unerwarteter Fehler ist aufgetreten.",
"chatScreenTitle": "Chat",
"chatFamilyDisclaimer": "*In the family chat, messages are shared with everyone in the group.",
"chatFamilyGroupTitle": "Family group chat",
"chatConversationTitleFallback": "Conversation",
"chatConversationEmpty": "Be the first to send a message.",
"chatComposerHint": "Type a message",
"chatComposerSendTooltip": "Send",
"chatTodayLabel": "Today",
"chatYesterdayLabel": "Yesterday",
"errorChatLoadConversation": "Failed to load the conversation.",
"errorChatSendMessage": "Could not send the message.",
"errorChatSendForbidden": "You don't have permission for this watch.",
"errorChatDeviceNotFound": "No watch selected.",
"chatComposerCameraOption": "Camera",
"chatComposerGalleryOption": "Gallery",
"chatComposerAttachTooltip": "Attach file",
"chatComposerRecordHoldHint": "Hold to record",
"chatRecordingCancelHint": "Slide ← to cancel",
"chatRecordingReleaseToCancel": "Release to cancel",
"chatPermissionCameraDenied": "Camera permission denied.",
"chatPermissionMicrophoneDenied": "Microphone permission denied.",
"chatPermissionPhotosDenied": "Photos permission denied.",
"chatPermissionOpenSettings": "Open settings",
"errorChatFileTooLarge": "File is too large.",
"errorChatFileUnsupported": "Unsupported file format.",
"errorChatFileUploadFailed": "Failed to upload file.",
"errorChatAudioRecordingFailed": "Audio recording failed.",
"errorChatImagePickFailed": "Could not pick the image.",
"chatImageMessageLabel": "Image",
"chatAudioMessageLabel": "Voice message"
}

View File

@@ -1108,5 +1108,35 @@
"errorValidation": "The information you entered is not valid.",
"errorScaRequired": "Additional authentication is required to continue.",
"errorDeviceNotOwned": "You don't have permission to access this device.",
"errorGeneric": "An unexpected error occurred."
"errorGeneric": "An unexpected error occurred.",
"chatScreenTitle": "Chat",
"chatFamilyDisclaimer": "*In the family chat, messages are shared with everyone in the group.",
"chatFamilyGroupTitle": "Family group chat",
"chatConversationTitleFallback": "Conversation",
"chatConversationEmpty": "Be the first to send a message.",
"chatComposerHint": "Type a message",
"chatComposerSendTooltip": "Send",
"chatTodayLabel": "Today",
"chatYesterdayLabel": "Yesterday",
"errorChatLoadConversation": "Failed to load the conversation.",
"errorChatSendMessage": "Could not send the message.",
"errorChatSendForbidden": "You don't have permission for this watch.",
"errorChatDeviceNotFound": "No watch selected.",
"chatComposerCameraOption": "Camera",
"chatComposerGalleryOption": "Gallery",
"chatComposerAttachTooltip": "Attach file",
"chatComposerRecordHoldHint": "Hold to record",
"chatRecordingCancelHint": "Slide ← to cancel",
"chatRecordingReleaseToCancel": "Release to cancel",
"chatPermissionCameraDenied": "Camera permission denied.",
"chatPermissionMicrophoneDenied": "Microphone permission denied.",
"chatPermissionPhotosDenied": "Photos permission denied.",
"chatPermissionOpenSettings": "Open settings",
"errorChatFileTooLarge": "File is too large.",
"errorChatFileUnsupported": "Unsupported file format.",
"errorChatFileUploadFailed": "Failed to upload file.",
"errorChatAudioRecordingFailed": "Audio recording failed.",
"errorChatImagePickFailed": "Could not pick the image.",
"chatImageMessageLabel": "Image",
"chatAudioMessageLabel": "Voice message"
}

View File

@@ -1108,5 +1108,35 @@
"errorValidation": "Los datos introducidos no son válidos.",
"errorScaRequired": "Se requiere autenticación adicional para continuar.",
"errorDeviceNotOwned": "No tienes permiso para acceder a este dispositivo.",
"errorGeneric": "Ha ocurrido un error inesperado."
"errorGeneric": "Ha ocurrido un error inesperado.",
"chatScreenTitle": "Chat",
"chatFamilyDisclaimer": "*En el chat familiar, los mensajes se compartirán con todos los miembros del grupo.",
"chatFamilyGroupTitle": "Chat de grupo familiar",
"chatConversationTitleFallback": "Conversación",
"chatConversationEmpty": "Sé el primero en escribir un mensaje.",
"chatComposerHint": "Escribe un mensaje",
"chatComposerSendTooltip": "Enviar",
"chatTodayLabel": "Hoy",
"chatYesterdayLabel": "Ayer",
"errorChatLoadConversation": "Error cargando la conversación.",
"errorChatSendMessage": "No se pudo enviar el mensaje.",
"errorChatSendForbidden": "No tienes permisos sobre este reloj.",
"errorChatDeviceNotFound": "No hay reloj seleccionado.",
"chatComposerCameraOption": "Cámara",
"chatComposerGalleryOption": "Galería",
"chatComposerAttachTooltip": "Adjuntar archivo",
"chatComposerRecordHoldHint": "Mantén pulsado para grabar",
"chatRecordingCancelHint": "Desliza ← para cancelar",
"chatRecordingReleaseToCancel": "Suelta para cancelar",
"chatPermissionCameraDenied": "Permiso de cámara denegado.",
"chatPermissionMicrophoneDenied": "Permiso de micrófono denegado.",
"chatPermissionPhotosDenied": "Permiso de galería denegado.",
"chatPermissionOpenSettings": "Abrir ajustes",
"errorChatFileTooLarge": "El archivo es demasiado grande.",
"errorChatFileUnsupported": "Formato de archivo no compatible.",
"errorChatFileUploadFailed": "No se pudo subir el archivo.",
"errorChatAudioRecordingFailed": "Error grabando audio.",
"errorChatImagePickFailed": "No se pudo seleccionar la imagen.",
"chatImageMessageLabel": "Imagen",
"chatAudioMessageLabel": "Mensaje de voz"
}

View File

@@ -1107,5 +1107,35 @@
"errorSessionExpired": "Votre session a expiré. Veuillez vous reconnecter.",
"errorValidation": "Les données saisies ne sont pas valides.",
"errorScaRequired": "Une authentification supplémentaire est requise pour continuer.",
"errorDeviceNotOwned": "Vous n'avez pas l'autorisation d'accéder à cet appareil."
"errorDeviceNotOwned": "Vous n'avez pas l'autorisation d'accéder à cet appareil.",
"chatScreenTitle": "Chat",
"chatFamilyDisclaimer": "*In the family chat, messages are shared with everyone in the group.",
"chatFamilyGroupTitle": "Family group chat",
"chatConversationTitleFallback": "Conversation",
"chatConversationEmpty": "Be the first to send a message.",
"chatComposerHint": "Type a message",
"chatComposerSendTooltip": "Send",
"chatTodayLabel": "Today",
"chatYesterdayLabel": "Yesterday",
"errorChatLoadConversation": "Failed to load the conversation.",
"errorChatSendMessage": "Could not send the message.",
"errorChatSendForbidden": "You don't have permission for this watch.",
"errorChatDeviceNotFound": "No watch selected.",
"chatComposerCameraOption": "Camera",
"chatComposerGalleryOption": "Gallery",
"chatComposerAttachTooltip": "Attach file",
"chatComposerRecordHoldHint": "Hold to record",
"chatRecordingCancelHint": "Slide ← to cancel",
"chatRecordingReleaseToCancel": "Release to cancel",
"chatPermissionCameraDenied": "Camera permission denied.",
"chatPermissionMicrophoneDenied": "Microphone permission denied.",
"chatPermissionPhotosDenied": "Photos permission denied.",
"chatPermissionOpenSettings": "Open settings",
"errorChatFileTooLarge": "File is too large.",
"errorChatFileUnsupported": "Unsupported file format.",
"errorChatFileUploadFailed": "Failed to upload file.",
"errorChatAudioRecordingFailed": "Audio recording failed.",
"errorChatImagePickFailed": "Could not pick the image.",
"chatImageMessageLabel": "Image",
"chatAudioMessageLabel": "Voice message"
}

View File

@@ -1108,5 +1108,35 @@
"errorValidation": "I dati inseriti non sono validi.",
"errorScaRequired": "È richiesta un'autenticazione aggiuntiva per continuare.",
"errorDeviceNotOwned": "Non hai il permesso di accedere a questo dispositivo.",
"errorGeneric": "Si è verificato un errore imprevisto."
"errorGeneric": "Si è verificato un errore imprevisto.",
"chatScreenTitle": "Chat",
"chatFamilyDisclaimer": "*In the family chat, messages are shared with everyone in the group.",
"chatFamilyGroupTitle": "Family group chat",
"chatConversationTitleFallback": "Conversation",
"chatConversationEmpty": "Be the first to send a message.",
"chatComposerHint": "Type a message",
"chatComposerSendTooltip": "Send",
"chatTodayLabel": "Today",
"chatYesterdayLabel": "Yesterday",
"errorChatLoadConversation": "Failed to load the conversation.",
"errorChatSendMessage": "Could not send the message.",
"errorChatSendForbidden": "You don't have permission for this watch.",
"errorChatDeviceNotFound": "No watch selected.",
"chatComposerCameraOption": "Camera",
"chatComposerGalleryOption": "Gallery",
"chatComposerAttachTooltip": "Attach file",
"chatComposerRecordHoldHint": "Hold to record",
"chatRecordingCancelHint": "Slide ← to cancel",
"chatRecordingReleaseToCancel": "Release to cancel",
"chatPermissionCameraDenied": "Camera permission denied.",
"chatPermissionMicrophoneDenied": "Microphone permission denied.",
"chatPermissionPhotosDenied": "Photos permission denied.",
"chatPermissionOpenSettings": "Open settings",
"errorChatFileTooLarge": "File is too large.",
"errorChatFileUnsupported": "Unsupported file format.",
"errorChatFileUploadFailed": "Failed to upload file.",
"errorChatAudioRecordingFailed": "Audio recording failed.",
"errorChatImagePickFailed": "Could not pick the image.",
"chatImageMessageLabel": "Image",
"chatAudioMessageLabel": "Voice message"
}

View File

@@ -1108,5 +1108,35 @@
"errorValidation": "Os dados introduzidos não são válidos.",
"errorScaRequired": "É necessária autenticação adicional para continuar.",
"errorDeviceNotOwned": "Não tens permissão para aceder a este dispositivo.",
"errorGeneric": "Ocorreu um erro inesperado."
"errorGeneric": "Ocorreu um erro inesperado.",
"chatScreenTitle": "Chat",
"chatFamilyDisclaimer": "*In the family chat, messages are shared with everyone in the group.",
"chatFamilyGroupTitle": "Family group chat",
"chatConversationTitleFallback": "Conversation",
"chatConversationEmpty": "Be the first to send a message.",
"chatComposerHint": "Type a message",
"chatComposerSendTooltip": "Send",
"chatTodayLabel": "Today",
"chatYesterdayLabel": "Yesterday",
"errorChatLoadConversation": "Failed to load the conversation.",
"errorChatSendMessage": "Could not send the message.",
"errorChatSendForbidden": "You don't have permission for this watch.",
"errorChatDeviceNotFound": "No watch selected.",
"chatComposerCameraOption": "Camera",
"chatComposerGalleryOption": "Gallery",
"chatComposerAttachTooltip": "Attach file",
"chatComposerRecordHoldHint": "Hold to record",
"chatRecordingCancelHint": "Slide ← to cancel",
"chatRecordingReleaseToCancel": "Release to cancel",
"chatPermissionCameraDenied": "Camera permission denied.",
"chatPermissionMicrophoneDenied": "Microphone permission denied.",
"chatPermissionPhotosDenied": "Photos permission denied.",
"chatPermissionOpenSettings": "Open settings",
"errorChatFileTooLarge": "File is too large.",
"errorChatFileUnsupported": "Unsupported file format.",
"errorChatFileUploadFailed": "Failed to upload file.",
"errorChatAudioRecordingFailed": "Audio recording failed.",
"errorChatImagePickFailed": "Could not pick the image.",
"chatImageMessageLabel": "Image",
"chatAudioMessageLabel": "Voice message"
}

View File

@@ -201,6 +201,27 @@ class I18n {
static const String channelOnline = 'channelOnline';
static const String channelStore = 'channelStore';
static const String chat = 'chat';
static const String chatAudioMessageLabel = 'chatAudioMessageLabel';
static const String chatComposerAttachTooltip = 'chatComposerAttachTooltip';
static const String chatComposerCameraOption = 'chatComposerCameraOption';
static const String chatComposerGalleryOption = 'chatComposerGalleryOption';
static const String chatComposerHint = 'chatComposerHint';
static const String chatComposerRecordHoldHint = 'chatComposerRecordHoldHint';
static const String chatComposerSendTooltip = 'chatComposerSendTooltip';
static const String chatConversationEmpty = 'chatConversationEmpty';
static const String chatConversationTitleFallback = 'chatConversationTitleFallback';
static const String chatFamilyDisclaimer = 'chatFamilyDisclaimer';
static const String chatFamilyGroupTitle = 'chatFamilyGroupTitle';
static const String chatImageMessageLabel = 'chatImageMessageLabel';
static const String chatPermissionCameraDenied = 'chatPermissionCameraDenied';
static const String chatPermissionMicrophoneDenied = 'chatPermissionMicrophoneDenied';
static const String chatPermissionOpenSettings = 'chatPermissionOpenSettings';
static const String chatPermissionPhotosDenied = 'chatPermissionPhotosDenied';
static const String chatRecordingCancelHint = 'chatRecordingCancelHint';
static const String chatRecordingReleaseToCancel = 'chatRecordingReleaseToCancel';
static const String chatScreenTitle = 'chatScreenTitle';
static const String chatTodayLabel = 'chatTodayLabel';
static const String chatYesterdayLabel = 'chatYesterdayLabel';
static const String checkEmail1 = 'checkEmail1';
static const String checkEmail2 = 'checkEmail2';
static const String checkSms1 = 'checkSms1';
@@ -402,6 +423,15 @@ class I18n {
static const String errorBirthCountryRequired = 'errorBirthCountryRequired';
static const String errorBirthDateRequired = 'errorBirthDateRequired';
static const String errorCall = 'errorCall';
static const String errorChatAudioRecordingFailed = 'errorChatAudioRecordingFailed';
static const String errorChatDeviceNotFound = 'errorChatDeviceNotFound';
static const String errorChatFileTooLarge = 'errorChatFileTooLarge';
static const String errorChatFileUnsupported = 'errorChatFileUnsupported';
static const String errorChatFileUploadFailed = 'errorChatFileUploadFailed';
static const String errorChatImagePickFailed = 'errorChatImagePickFailed';
static const String errorChatLoadConversation = 'errorChatLoadConversation';
static const String errorChatSendForbidden = 'errorChatSendForbidden';
static const String errorChatSendMessage = 'errorChatSendMessage';
static const String errorContactsMax = 'errorContactsMax';
static const String errorContactsMin = 'errorContactsMin';
static const String errorDeviceDisconnected = 'errorDeviceDisconnected';

View File

@@ -7,6 +7,7 @@ export 'src/clients/firebase_tracking_client.dart';
export 'src/mixins/account_tracking.dart';
export 'src/mixins/app_update_tracking.dart';
export 'src/mixins/auth_tracking.dart';
export 'src/mixins/chat_tracking.dart';
export 'src/mixins/contacts_tracking.dart';
export 'src/mixins/control_panel_tracking.dart';
export 'src/mixins/device_setup_tracking.dart';

View File

@@ -0,0 +1,55 @@
import 'package:sf_tracking/src/tracking.dart';
const _prefix = 'legacy_chat';
/// Mixed into [SfTrackingRepository]. Events here purposely avoid sending
/// raw chat ids, user ids or delegation ids: those are PII and Firebase
/// Analytics already receives the user identity via setUserId.
mixin ChatTracking on Tracking {
Future<void> legacyChatOpened({required bool isGroup}) =>
trackEvent('${_prefix}_opened', {'is_group': isGroup.toString()});
Future<void> legacyChatMessageSent({
required String type,
required bool isGroup,
int? memberCount,
}) => trackEvent('${_prefix}_message_sent', {
'type': type,
'is_group': isGroup.toString(),
if (memberCount != null) 'member_count': memberCount,
});
/// [originalSizeBytes] is the size of the file the user picked, before
/// in-app compression. Use it to understand what users send, not bandwidth.
Future<void> legacyChatImageSent({
required String source,
required bool isGroup,
int? memberCount,
int? originalSizeBytes,
}) => trackEvent('${_prefix}_image_sent', {
'source': source,
'is_group': isGroup.toString(),
if (memberCount != null) 'member_count': memberCount,
if (originalSizeBytes != null) 'original_size_bytes': originalSizeBytes,
});
Future<void> legacyChatAudioSent({
required bool isGroup,
int? memberCount,
required int durationMs,
int? sizeBytes,
}) => trackEvent('${_prefix}_audio_sent', {
'is_group': isGroup.toString(),
if (memberCount != null) 'member_count': memberCount,
'duration_ms': durationMs,
if (sizeBytes != null) 'size_bytes': sizeBytes,
});
Future<void> legacyChatPermissionDenied({
required String permission,
required bool permanently,
}) => trackEvent('${_prefix}_permission_denied', {
'permission': permission,
'permanently': permanently.toString(),
});
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:sf_tracking/src/mixins/account_tracking.dart';
import 'package:sf_tracking/src/mixins/app_update_tracking.dart';
import 'package:sf_tracking/src/mixins/auth_tracking.dart';
import 'package:sf_tracking/src/mixins/chat_tracking.dart';
import 'package:sf_tracking/src/mixins/contacts_tracking.dart';
import 'package:sf_tracking/src/mixins/control_panel_tracking.dart';
import 'package:sf_tracking/src/mixins/device_setup_tracking.dart';
@@ -31,7 +32,8 @@ class SfTrackingRepository extends Tracking
OnboardingTracking,
LocationTracking,
ControlPanelTracking,
AppUpdateTracking
AppUpdateTracking,
ChatTracking
implements NavigationTracking {
SfTrackingRepository({required List<TrackingClient> clients})
: _clients = clients;

View File

@@ -81,6 +81,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audioplayers:
dependency: transitive
description:
name: audioplayers
sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
url: "https://pub.dev"
source: hosted
version: "6.6.0"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev"
source: hosted
version: "6.4.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
url: "https://pub.dev"
source: hosted
version: "4.2.1"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
url: "https://pub.dev"
source: hosted
version: "7.1.1"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
url: "https://pub.dev"
source: hosted
version: "5.2.0"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
url: "https://pub.dev"
source: hosted
version: "4.3.0"
boolean_selector:
dependency: transitive
description:
@@ -630,6 +686,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_contacts:
dependency: transitive
description:
@@ -643,6 +707,54 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_image_compress:
dependency: transitive
description:
name: flutter_image_compress
sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
flutter_image_compress_common:
dependency: transitive
description:
name: flutter_image_compress_common
sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb
url: "https://pub.dev"
source: hosted
version: "1.0.6"
flutter_image_compress_macos:
dependency: transitive
description:
name: flutter_image_compress_macos
sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_image_compress_ohos:
dependency: transitive
description:
name: flutter_image_compress_ohos
sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51
url: "https://pub.dev"
source: hosted
version: "0.0.3"
flutter_image_compress_platform_interface:
dependency: transitive
description:
name: flutter_image_compress_platform_interface
sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
flutter_image_compress_web:
dependency: transitive
description:
name: flutter_image_compress_web
sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96
url: "https://pub.dev"
source: hosted
version: "0.1.5"
flutter_launcher_icons:
dependency: transitive
description:
@@ -1468,6 +1580,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
record:
dependency: transitive
description:
name: record
sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277
url: "https://pub.dev"
source: hosted
version: "6.2.0"
record_android:
dependency: transitive
description:
name: record_android
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
riverpod:
dependency: transitive
description:
@@ -1689,6 +1865,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
url: "https://pub.dev"
source: hosted
version: "2.4.2+2"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
@@ -1737,6 +1953,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:

View File

@@ -16,6 +16,7 @@ workspace:
- modules/payment/modules/profile
- modules/legacy
- modules/legacy/modules/account
- modules/legacy/modules/chat
- modules/legacy/modules/control_panel
- modules/legacy/modules/customer_service
- modules/legacy/modules/device_management