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:
@@ -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" />
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
record_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
2
modules/legacy/modules/chat/lib/chat.dart
Normal file
2
modules/legacy/modules/chat/lib/chat.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
export 'src/features/chat_list/chat_list_builder.dart';
|
||||
export 'src/features/chat_conversation/chat_conversation_builder.dart';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Binary file not shown.
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
enum ChatMediaSource { camera, gallery }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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>());
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
64
modules/legacy/modules/chat/pubspec.yaml
Normal file
64
modules/legacy/modules/chat/pubspec.yaml
Normal 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
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}$')));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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? ?? '',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
55
packages/sf_tracking/lib/src/mixins/chat_tracking.dart
Normal file
55
packages/sf_tracking/lib/src/mixins/chat_tracking.dart
Normal 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(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
224
pubspec.lock
224
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user