Merge branch 'feature/FullStack' of https://github.com/KometTeam/Komet into feature/FullStack
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart' show Locale;
|
||||
import '../api.dart';
|
||||
import '../../core/protocol/opcode_map.dart';
|
||||
import '../../core/protocol/packet.dart';
|
||||
@@ -431,6 +432,82 @@ class AccountModule {
|
||||
return config;
|
||||
}
|
||||
|
||||
Future<ProfileData> updateProfileName(String firstName, String? lastName) async {
|
||||
_ensureOnline();
|
||||
final payload = <dynamic, dynamic>{
|
||||
'firstName': firstName,
|
||||
};
|
||||
if (lastName != null) payload['lastName'] = lastName;
|
||||
final packet = await _api.sendRequest(Opcode.profile, payload);
|
||||
if (packet.isError) {
|
||||
throw Exception(packet.payload?.toString() ?? 'Server error');
|
||||
}
|
||||
final data = packet.payload as Map?;
|
||||
if (data == null) throw Exception('Empty response');
|
||||
final profile = data['profile'] as Map?;
|
||||
if (profile == null) throw Exception('No profile in response');
|
||||
final contact = profile['contact'] as Map?;
|
||||
if (contact == null) throw Exception('No contact in response');
|
||||
final newProfile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
await AppDatabase.saveProfile(newProfile, isActive: true);
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
Future<ProfileData> updateProfileAvatar(String photoToken, String avatarType) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.profile, {
|
||||
'photoToken': photoToken,
|
||||
'avatarType': avatarType,
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw Exception(packet.payload?.toString() ?? 'Server error');
|
||||
}
|
||||
final data = packet.payload as Map?;
|
||||
if (data == null) throw Exception('Empty response');
|
||||
final profile = data['profile'] as Map?;
|
||||
if (profile == null) throw Exception('No profile in response');
|
||||
final contact = profile['contact'] as Map?;
|
||||
if (contact == null) throw Exception('No contact in response');
|
||||
final newProfile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
await AppDatabase.saveProfile(newProfile, isActive: true);
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
Future<String> getAvatarUploadUrl() async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.photoUpload, {
|
||||
'count': 1,
|
||||
'profile': true,
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw Exception(packet.payload?.toString() ?? 'Server error');
|
||||
}
|
||||
final data = packet.payload as Map?;
|
||||
if (data == null) throw Exception('Empty response');
|
||||
final url = data['url'] as String?;
|
||||
if (url == null) throw Exception('No url in response');
|
||||
return url;
|
||||
}
|
||||
|
||||
Future<ProfileData> removeProfilePhoto(int photoId) async {
|
||||
_ensureOnline();
|
||||
final packet = await _api.sendRequest(Opcode.removeContactPhoto, {
|
||||
'photoId': photoId,
|
||||
});
|
||||
if (packet.isError) {
|
||||
throw Exception(packet.payload?.toString() ?? 'Server error');
|
||||
}
|
||||
final data = packet.payload as Map?;
|
||||
if (data == null) throw Exception('Empty response');
|
||||
final profile = data['profile'] as Map?;
|
||||
if (profile == null) throw Exception('No profile in response');
|
||||
final contact = profile['contact'] as Map?;
|
||||
if (contact == null) throw Exception('No contact in response');
|
||||
final newProfile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
await AppDatabase.saveProfile(newProfile, isActive: true);
|
||||
return newProfile;
|
||||
}
|
||||
|
||||
// 2FA Creation (when not set)
|
||||
Future<String> create2faTrack() async {
|
||||
_ensureOnline();
|
||||
@@ -931,7 +1008,7 @@ class AccountModule {
|
||||
throw Exception('login: отсутствует profile.contact в ответе');
|
||||
}
|
||||
final profile = ProfileData.fromServerMap(contact.cast<dynamic, dynamic>());
|
||||
await AppDatabase.saveProfile(profile);
|
||||
await AppDatabase.saveProfile(profile, isActive: true);
|
||||
await AppDatabase.setActiveAccount(profile.id);
|
||||
|
||||
await _saveSyncState(data, serverTime, profile.id);
|
||||
|
||||
@@ -23,6 +23,34 @@ class ContactCache {
|
||||
static String? getAvatar(int id) => _avatarCache[id];
|
||||
}
|
||||
|
||||
class TranscriptionResult {
|
||||
final int status;
|
||||
final String? text;
|
||||
final String? messageId;
|
||||
final int? chatId;
|
||||
final int? mediaId;
|
||||
|
||||
TranscriptionResult({
|
||||
required this.status,
|
||||
this.text,
|
||||
this.messageId,
|
||||
this.chatId,
|
||||
this.mediaId,
|
||||
});
|
||||
}
|
||||
|
||||
class TranscriptionCache {
|
||||
static final Map<String, TranscriptionResult> _cache = {};
|
||||
|
||||
static void put(String messageId, TranscriptionResult result) {
|
||||
_cache[messageId] = result;
|
||||
}
|
||||
|
||||
static TranscriptionResult? get(String messageId) => _cache[messageId];
|
||||
|
||||
static bool has(String messageId) => _cache.containsKey(messageId);
|
||||
}
|
||||
|
||||
class CachedMessage {
|
||||
final String id;
|
||||
final int accountId;
|
||||
@@ -247,6 +275,35 @@ class MessagesModule {
|
||||
await _api.sendRequest(Opcode.msgSend, payload);
|
||||
}
|
||||
|
||||
Future<TranscriptionResult> requestTranscription(
|
||||
int chatId,
|
||||
int messageId,
|
||||
int mediaId,
|
||||
) async {
|
||||
final payload = {
|
||||
'chatId': chatId,
|
||||
'messageId': messageId,
|
||||
'mediaId': mediaId,
|
||||
};
|
||||
|
||||
final response = await _api.sendRequest(Opcode.audioTranscription, payload);
|
||||
if (!response.isOk) return TranscriptionResult(status: -1);
|
||||
|
||||
final data = response.payload;
|
||||
if (data is! Map) return TranscriptionResult(status: -1);
|
||||
|
||||
final transcriptionStatus = data['transcriptionStatus'] as int? ?? -1;
|
||||
if (transcriptionStatus == 1) {
|
||||
final text = data['transcription'] as String? ?? '';
|
||||
if (text.isEmpty) {
|
||||
return TranscriptionResult(status: 1, text: 'не удалось распознать текст');
|
||||
}
|
||||
return TranscriptionResult(status: 1, text: text);
|
||||
}
|
||||
|
||||
return TranscriptionResult(status: transcriptionStatus);
|
||||
}
|
||||
|
||||
Future<Uint8List?> downloadPhoto(String baseUrl, String photoToken) async {
|
||||
try {
|
||||
final response = await _api.sendRequest(Opcode.fileDownload, {
|
||||
|
||||
@@ -170,6 +170,10 @@ abstract class Opcode {
|
||||
static const int notifBanners = 292; // Баннеры
|
||||
static const int notifFolders = 277; // Обновление папок
|
||||
|
||||
// ── Transcription ───────────────────────────────────────────────────
|
||||
static const int audioTranscription = 202; // Запрос транскрибации аудио
|
||||
static const int transcriptionResult = 293; // Результат транскрибации (push)
|
||||
|
||||
// ── Misc ───────────────────────────────────────────────────────────
|
||||
static const int okToken = 158; // OK-токен
|
||||
static const int webAppInitData = 160; // Данные WebApp
|
||||
@@ -332,6 +336,8 @@ abstract class Opcode {
|
||||
notifProfile: 'NOTIF_PROFILE',
|
||||
notifBanners: 'NOTIF_BANNERS',
|
||||
notifFolders: 'NOTIF_FOLDERS',
|
||||
audioTranscription: 'AUDIO_TRANSCRIPTION',
|
||||
transcriptionResult: 'TRANSCRIPTION_RESULT',
|
||||
okToken: 'OK_TOKEN',
|
||||
webAppInitData: 'WEB_APP_INIT_DATA',
|
||||
complain: 'COMPLAIN',
|
||||
|
||||
@@ -98,7 +98,7 @@ class ProfileData {
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDbRow() => {
|
||||
Map<String, dynamic> toDbRow({bool isActive = false}) => {
|
||||
'id': id,
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
@@ -109,6 +109,7 @@ class ProfileData {
|
||||
'country': country,
|
||||
'account_status': accountStatus,
|
||||
'update_time': updateTime,
|
||||
'is_active': isActive ? 1 : 0,
|
||||
'profile_options': profileOptions?.join(','),
|
||||
};
|
||||
}
|
||||
@@ -280,11 +281,11 @@ class AppDatabase {
|
||||
)
|
||||
''';
|
||||
|
||||
static Future<void> saveProfile(ProfileData profile) async {
|
||||
static Future<void> saveProfile(ProfileData profile, {bool isActive = true}) async {
|
||||
final db = await _instance;
|
||||
await db.insert(
|
||||
'profile',
|
||||
profile.toDbRow(),
|
||||
profile.toDbRow(isActive: isActive),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,12 @@ class PacketDispatcher {
|
||||
if (packet.cmd == CmdType.ok ||
|
||||
packet.cmd == CmdType.error ||
|
||||
packet.cmd == CmdType.notFound) {
|
||||
final payloadStr = packet.payload.toString();
|
||||
final displayPayload = packet.opcode == Opcode.login && payloadStr.length > 50
|
||||
? '${payloadStr.substring(0, 50)}...'
|
||||
: payloadStr;
|
||||
logger.i(
|
||||
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: ${packet.payload}}',
|
||||
'<= {ver: ${packet.api}, cmd: ${packet.cmd}, seq: ${packet.seq}, opcode: ${packet.opcode}, payload: $displayPayload}',
|
||||
);
|
||||
|
||||
final completer = _pendingRequests.remove(packet.seq);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../main.dart' show api;
|
||||
@@ -166,10 +167,11 @@ class _CallsTabState extends State<CallsTab> {
|
||||
),
|
||||
child: ClipOval(
|
||||
child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
call.avatarUrl!,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: call.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (context, url, error) =>
|
||||
_buildPlaceholderAvatar(cs, call.name),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, call.name),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:komet/backend/modules/messages.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -93,6 +94,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final List<ScrollController> _folderChatScrollControllers = [];
|
||||
final List<VoidCallback> _folderChatScrollListenerFns = [];
|
||||
final Set<String> _selectedChats = {};
|
||||
final Set<int> _inflightContactIds = {};
|
||||
|
||||
DateTime _storiesRevealLayoutSettleUntil =
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
@@ -157,14 +159,11 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
);
|
||||
_navPageAnimController =
|
||||
AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
value: 1.0,
|
||||
)..addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
_navPageAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
value: 1.0,
|
||||
);
|
||||
_shimmerController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
@@ -262,6 +261,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
}
|
||||
_isInitialLoading = false;
|
||||
});
|
||||
_prefetchContactsForChats(chats);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_jumpFolderPageToSelection();
|
||||
@@ -315,6 +315,33 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _prefetchContactsForChats(List<CachedChat> chats) {
|
||||
final myId = _profile?.id;
|
||||
final ids = <int>{};
|
||||
for (final chat in chats) {
|
||||
if (chat.type == 'DIALOG' && chat.id != 0) {
|
||||
for (final entry in chat.participants.entries) {
|
||||
if (entry.key != myId) {
|
||||
ids.add(entry.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
final senderId = chat.lastMsgSenderId;
|
||||
if (senderId != null) ids.add(senderId);
|
||||
}
|
||||
ids.removeWhere((id) => ContactCache.get(id) != null);
|
||||
ids.removeAll(_inflightContactIds);
|
||||
if (ids.isEmpty) return;
|
||||
_inflightContactIds.addAll(ids);
|
||||
for (final id in ids) {
|
||||
messagesModule.searchContactById(id).whenComplete(() {
|
||||
_inflightContactIds.remove(id);
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<CachedChat> _chatsForPageIndex(int pageIndex) {
|
||||
if (_folders.isEmpty) return _chats;
|
||||
if (pageIndex < 0 || pageIndex >= _folders.length) return _chats;
|
||||
@@ -1028,7 +1055,10 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
final chat = chats[index];
|
||||
|
||||
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
|
||||
final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key;
|
||||
final secondId = chat.participants.entries
|
||||
.where((entry) => entry.key != _profile?.id)
|
||||
.first
|
||||
.key;
|
||||
final name = ContactCache.get(secondId);
|
||||
final avatar = ContactCache.getAvatar(secondId);
|
||||
|
||||
@@ -1354,17 +1384,6 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
return lo + 4;
|
||||
}
|
||||
|
||||
final pageDisplayT = _effectivePageNavRowT(
|
||||
inactiveWidth: inactiveWidth,
|
||||
bubbleLeftForIndex: bubbleLeftForPageT,
|
||||
);
|
||||
|
||||
final showChatsFab =
|
||||
!_isSelectionMode &&
|
||||
(_navDragging || _navPageAnimController.isAnimating
|
||||
? pageDisplayT < 1.0
|
||||
: _currentNavIndex == 0);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ClipRect(
|
||||
@@ -1378,8 +1397,8 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
child: SizedBox(
|
||||
width: pageW * 4,
|
||||
height: pageH,
|
||||
child: Transform.translate(
|
||||
offset: Offset(-pageDisplayT * pageW, 0),
|
||||
child: AnimatedBuilder(
|
||||
animation: _navPageAnimController,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -1413,15 +1432,37 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
builder: (context, child) {
|
||||
final pageDisplayT = _effectivePageNavRowT(
|
||||
inactiveWidth: inactiveWidth,
|
||||
bubbleLeftForIndex: bubbleLeftForPageT,
|
||||
);
|
||||
return Transform.translate(
|
||||
offset: Offset(-pageDisplayT * pageW, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildDockedBottomNav(cs, navInnerW, bottomInset),
|
||||
ListenableBuilder(
|
||||
listenable: _fabController,
|
||||
builder: (context, child) {
|
||||
AnimatedBuilder(
|
||||
animation: Listenable.merge([
|
||||
_fabController,
|
||||
_navPageAnimController,
|
||||
]),
|
||||
builder: (context, _) {
|
||||
final pageDisplayT = _effectivePageNavRowT(
|
||||
inactiveWidth: inactiveWidth,
|
||||
bubbleLeftForIndex: bubbleLeftForPageT,
|
||||
);
|
||||
final showChatsFab =
|
||||
!_isSelectionMode &&
|
||||
(_navDragging || _navPageAnimController.isAnimating
|
||||
? pageDisplayT < 1.0
|
||||
: _currentNavIndex == 0);
|
||||
final double val = Curves.easeOutCubic.transform(
|
||||
_fabController.value,
|
||||
);
|
||||
@@ -1563,7 +1604,7 @@ class _ChatListScreenState extends State<ChatListScreen>
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
backgroundImage: NetworkImage(imageUrl),
|
||||
backgroundImage: CachedNetworkImageProvider(imageUrl),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -1717,7 +1758,7 @@ Navigator.push(
|
||||
radius: 24,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
backgroundImage: imageUrl.isNotEmpty
|
||||
? NetworkImage(imageUrl)
|
||||
? CachedNetworkImageProvider(imageUrl)
|
||||
: null,
|
||||
child: imageUrl.isEmpty
|
||||
? Text(
|
||||
@@ -2019,7 +2060,7 @@ Navigator.push(
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundImage: NetworkImage(imageUrl),
|
||||
backgroundImage: CachedNetworkImageProvider(imageUrl),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:komet/backend/modules/chats.dart';
|
||||
import 'package:komet/frontend/screens/chats/chat_info_screen.dart';
|
||||
@@ -258,18 +259,18 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: Size.fromHeight(kToolbarHeight),
|
||||
preferredSize: Size.fromHeight(kToolbarHeight),
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => ChatInfoScreen(
|
||||
chatId: widget.chatId,
|
||||
chatId: widget.chatId,
|
||||
name: widget.name,
|
||||
imageUrl: widget.imageUrl,
|
||||
chatType: widget.chatType)
|
||||
)
|
||||
),
|
||||
child: AppBar(
|
||||
child: AppBar(
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
foregroundColor: cs.onSurface,
|
||||
elevation: 0,
|
||||
@@ -285,7 +286,7 @@ class _ChatScreenState extends State<ChatScreen>
|
||||
if (widget.imageUrl.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: NetworkImage(widget.imageUrl),
|
||||
backgroundImage: CachedNetworkImageProvider(widget.imageUrl),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
@@ -84,10 +85,11 @@ class _ContactsTabState extends State<ContactsTab> {
|
||||
),
|
||||
child: ClipOval(
|
||||
child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
contact.baseUrl!,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: contact.baseUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (context, url, error) =>
|
||||
_buildPlaceholderAvatar(cs, nameToDisplay),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, nameToDisplay),
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart' show accountModule, KometApp;
|
||||
import '../../widgets/custom_notification.dart';
|
||||
|
||||
class EditProfileScreen extends StatefulWidget {
|
||||
const EditProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
State<EditProfileScreen> createState() => _EditProfileScreenState();
|
||||
}
|
||||
|
||||
class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
String? _avatarUrl;
|
||||
int? _photoId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfile();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
final profile = await AppDatabase.loadActiveProfile();
|
||||
if (!mounted) return;
|
||||
if (profile != null) {
|
||||
_firstNameController.text = profile.firstName;
|
||||
_lastNameController.text = profile.lastName ?? '';
|
||||
_avatarUrl = profile.baseUrl;
|
||||
_photoId = profile.photoId;
|
||||
setState(() => _isLoading = false);
|
||||
} else {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveName() async {
|
||||
if (_isSaving) return;
|
||||
final firstName = _firstNameController.text.trim();
|
||||
if (firstName.isEmpty) {
|
||||
if (mounted) showCustomNotification(context, 'Имя не может быть пустым');
|
||||
return;
|
||||
}
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final newProfile = await accountModule.updateProfileName(
|
||||
firstName,
|
||||
_lastNameController.text.trim().isEmpty ? null : _lastNameController.text.trim(),
|
||||
);
|
||||
_avatarUrl = newProfile.baseUrl;
|
||||
_photoId = newProfile.photoId;
|
||||
KometApp.stateOf(context)?.notifyProfileUpdate();
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Имя сохранено');
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _changeAvatar() async {
|
||||
if (_isSaving) return;
|
||||
try {
|
||||
final uploadUrl = await accountModule.getAvatarUploadUrl();
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Загрузка аватарки: $uploadUrl (пока нет)');
|
||||
} catch (e) {
|
||||
if (mounted) showCustomNotification(context, 'Ошибка: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeAvatar() async {
|
||||
if (_isSaving || _photoId == null) return;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final newProfile = await accountModule.removeProfilePhoto(_photoId!);
|
||||
_avatarUrl = newProfile.baseUrl;
|
||||
_photoId = newProfile.photoId;
|
||||
KometApp.stateOf(context)?.notifyProfileUpdate();
|
||||
if (mounted) {
|
||||
showCustomNotification(context, 'Фото удалено');
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showCustomNotification(context, 'Ошибка: $e');
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.surface,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Symbols.arrow_back, color: cs.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
l10n?.editProfileTitle ?? 'Edit Profile',
|
||||
style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isLoading || _isSaving ? null : _saveName,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(
|
||||
l10n?.editProfileSave ?? 'Save',
|
||||
style: TextStyle(color: cs.primary, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: cs.primary.withValues(alpha: 0.5),
|
||||
width: 2.5,
|
||||
),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _avatarUrl != null && _avatarUrl!.isNotEmpty
|
||||
? Image.network(_avatarUrl!, fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: cs.primaryContainer,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_firstNameController.text.isNotEmpty
|
||||
? _firstNameController.text[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: cs.onPrimaryContainer,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(Symbols.camera_alt, color: cs.onPrimary, size: 20),
|
||||
onPressed: _changeAvatar,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_photoId != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: _removeAvatar,
|
||||
child: Text(
|
||||
l10n?.editProfileRemovePhoto ?? 'Remove photo',
|
||||
style: TextStyle(color: cs.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
_buildTextField(
|
||||
l10n?.editProfileFirstName ?? 'First name',
|
||||
_firstNameController,
|
||||
cs,
|
||||
enabled: !_isSaving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildTextField(
|
||||
l10n?.editProfileLastName ?? 'Last name',
|
||||
_lastNameController,
|
||||
cs,
|
||||
enabled: !_isSaving,
|
||||
),
|
||||
const SizedBox(height: 120),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField(String label, TextEditingController controller, ColorScheme cs, {bool enabled = true}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text(label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHigh,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -115,11 +115,11 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSectionTitle(l10n.infoAccountSection, cs),
|
||||
...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key]), cs)),
|
||||
...accountKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(info[e.key], e.key), cs)),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionTitle(l10n.infoServerSection, cs),
|
||||
...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key]), cs)),
|
||||
...serverKeys.entries.map((e) => _buildRow(e.key, e.value, _formatValue(server?[e.key], e.key), cs)),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
_buildSectionTitle(l10n.infoYMapSection, cs),
|
||||
@@ -240,28 +240,17 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatValue(dynamic value) {
|
||||
String _formatValue(dynamic value, String key) {
|
||||
if (value == null) return '-';
|
||||
if (value is Map && value.containsKey('chatMarker')) {
|
||||
final ts = value['chatMarker'] as int?;
|
||||
if (ts != null) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(ts);
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '-';
|
||||
return ts != null ? _formatTs(ts) : '-';
|
||||
}
|
||||
if (value is int && value > 1000000000000) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(value);
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
if (value is int && value > 86400) {
|
||||
if (value is int && value > 1000000000000) return _formatTs(value);
|
||||
if (key == 'edit-timeout' && value is int && value > 0) {
|
||||
final weeks = value ~/ 604800;
|
||||
final days = (value % 604800) ~/ 86400;
|
||||
if (weeks > 0) {
|
||||
return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim();
|
||||
}
|
||||
if (weeks > 0) return '$weeks ${_w(weeks)} ${days > 0 ? '$days ${_d(days)}' : ''}'.trim();
|
||||
final h = value ~/ 3600;
|
||||
final m = (value % 3600) ~/ 60;
|
||||
if (h > 0) return '${h}h ${m}m';
|
||||
@@ -270,6 +259,13 @@ class _InfoScreenState extends State<InfoScreen> {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
String _formatTs(int ts) {
|
||||
if (ts < 1000000000000) return ts.toString();
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(ts);
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _w(int n) {
|
||||
final m = n % 10;
|
||||
if (m == 1 && n != 11) return 'нед';
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../core/storage/app_database.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../main.dart';
|
||||
import '../auth/proxy_settings_sheet.dart';
|
||||
import 'debug_menu_screen.dart';
|
||||
import 'devices_screen.dart';
|
||||
import 'edit_profile_screen.dart';
|
||||
import 'info_screen.dart';
|
||||
import 'security_screen.dart';
|
||||
import 'spoof_screen.dart';
|
||||
@@ -26,17 +29,25 @@ class _SettingsTabState extends State<SettingsTab> {
|
||||
bool _debugMenuVisible = false;
|
||||
int _versionSecretTapCount = 0;
|
||||
Timer? _versionSecretTapResetTimer;
|
||||
StreamSubscription? _profileUpdateSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProfile();
|
||||
_loadAppVersion();
|
||||
final appState = KometApp.stateOf(context);
|
||||
if (appState != null) {
|
||||
_profileUpdateSub = appState.profileUpdateStream.listen((_) {
|
||||
if (mounted) _loadProfile();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_versionSecretTapResetTimer?.cancel();
|
||||
_profileUpdateSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -316,7 +327,14 @@ child: _buildSection(
|
||||
size: 22,
|
||||
weight: 400,
|
||||
),
|
||||
onPressed: () {},
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const EditProfileScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -333,10 +351,11 @@ child: _buildSection(
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
_profile!.baseUrl!,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: _profile!.baseUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (context, url, error) =>
|
||||
_buildPlaceholderAvatar(cs, name),
|
||||
)
|
||||
: _buildPlaceholderAvatar(cs, name),
|
||||
@@ -514,7 +533,21 @@ class _PhoneSpoilerState extends State<_PhoneSpoiler>
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat();
|
||||
);
|
||||
if (!widget.isVisible) {
|
||||
_controller.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _PhoneSpoiler oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isVisible == oldWidget.isVisible) return;
|
||||
if (widget.isVisible) {
|
||||
_controller.stop();
|
||||
} else if (!_controller.isAnimating) {
|
||||
_controller.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -10,7 +10,7 @@ void showCustomNotificationOnOverlay(OverlayState overlay, String message) {
|
||||
builder: (context) => CustomNotification(message: message),
|
||||
);
|
||||
overlay.insert(entry);
|
||||
Future.delayed(const Duration(milliseconds: 1900), () {
|
||||
Future.delayed(const Duration(milliseconds: 2600), () {
|
||||
entry.remove();
|
||||
});
|
||||
}
|
||||
@@ -38,7 +38,7 @@ class _CustomNotificationState extends State<CustomNotification>
|
||||
);
|
||||
_opacity = Tween<double>(begin: 0.0, end: 1.0).animate(_controller);
|
||||
_controller.forward();
|
||||
Future.delayed(const Duration(milliseconds: 1600), () {
|
||||
Future.delayed(const Duration(milliseconds: 2300), () {
|
||||
if (mounted) _controller.reverse();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:komet/backend/modules/chats.dart';
|
||||
import 'package:komet/backend/modules/contacts.dart';
|
||||
@@ -111,19 +112,22 @@ class MessageBubble extends StatelessWidget {
|
||||
return MessageType.text;
|
||||
}
|
||||
|
||||
// скругление уже смешариков т.е сообщений, те которые isme ? .. Это наши, после : это чужие
|
||||
BorderRadius get _borderRadius {
|
||||
final topRadius = Radius.circular(bubbleBorderRadius);
|
||||
final bottomRadius = Radius.circular(bubbleBorderRadius);
|
||||
final smallRadius = const Radius.circular(4);
|
||||
|
||||
final cornerTL = isMe ? smallRadius : topRadius;
|
||||
final cornerTR = isMe ? topRadius : smallRadius;
|
||||
final cornerBL = isMe ? smallRadius : topRadius;
|
||||
final cornerBR = isMe ? topRadius : smallRadius;
|
||||
|
||||
if (_hasPhotoWithCaption &&
|
||||
(shape == BubbleShape.singleTop ||
|
||||
shape == BubbleShape.singleMiddle ||
|
||||
shape == BubbleShape.singleBottom)) {
|
||||
return BorderRadius.only(
|
||||
topLeft: topRadius,
|
||||
topRight: isMe ? topRadius : topRadius,
|
||||
topRight: topRadius,
|
||||
bottomLeft: smallRadius,
|
||||
bottomRight: smallRadius,
|
||||
);
|
||||
@@ -135,16 +139,16 @@ class MessageBubble extends StatelessWidget {
|
||||
return BorderRadius.only(
|
||||
topLeft: smallRadius,
|
||||
topRight: smallRadius,
|
||||
bottomLeft: isMe ? smallRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : bottomRadius,
|
||||
bottomLeft: smallRadius,
|
||||
bottomRight: isMe ? smallRadius : topRadius,
|
||||
);
|
||||
}
|
||||
|
||||
switch (shape) {
|
||||
case BubbleShape.singleTop:
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? topRadius : smallRadius,
|
||||
topRight: isMe ? smallRadius : topRadius,
|
||||
topLeft: cornerTL,
|
||||
topRight: cornerTR,
|
||||
bottomLeft: smallRadius,
|
||||
bottomRight: smallRadius,
|
||||
);
|
||||
@@ -152,22 +156,22 @@ class MessageBubble extends StatelessWidget {
|
||||
return BorderRadius.only(
|
||||
topLeft: smallRadius,
|
||||
topRight: smallRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : topRadius,
|
||||
bottomLeft: cornerBL,
|
||||
bottomRight: cornerBR,
|
||||
);
|
||||
case BubbleShape.singleMiddle:
|
||||
return BorderRadius.only(
|
||||
topLeft: topRadius,
|
||||
topRight: topRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : topRadius,
|
||||
topLeft: cornerTL,
|
||||
topRight: cornerTR,
|
||||
bottomLeft: cornerBL,
|
||||
bottomRight: cornerBR,
|
||||
);
|
||||
case BubbleShape.groupedMiddle:
|
||||
return BorderRadius.only(
|
||||
topLeft: isMe ? topRadius : smallRadius,
|
||||
topRight: isMe ? smallRadius : smallRadius,
|
||||
bottomLeft: isMe ? topRadius : smallRadius,
|
||||
bottomRight: isMe ? smallRadius : smallRadius,
|
||||
topLeft: cornerTL,
|
||||
topRight: smallRadius,
|
||||
bottomLeft: cornerBL,
|
||||
bottomRight: smallRadius,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -272,13 +276,13 @@ class MessageBubble extends StatelessWidget {
|
||||
case MessageType.voice:
|
||||
switch (shape) {
|
||||
case BubbleShape.groupedMiddle:
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
|
||||
case BubbleShape.singleTop:
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
||||
case BubbleShape.singleBottom:
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 6);
|
||||
case BubbleShape.singleMiddle:
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 4);
|
||||
}
|
||||
}
|
||||
return const EdgeInsets.symmetric(horizontal: 14, vertical: 10);
|
||||
@@ -314,7 +318,7 @@ class MessageBubble extends StatelessWidget {
|
||||
? [
|
||||
CircleAvatar(
|
||||
radius: 15,
|
||||
backgroundImage: NetworkImage(senderAvatar),
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
]
|
||||
@@ -455,7 +459,7 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: NetworkImage(senderAvatar),
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -707,7 +711,7 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: NetworkImage(senderAvatar),
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -780,7 +784,7 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: NetworkImage(senderAvatar),
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -846,7 +850,7 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: NetworkImage(senderAvatar),
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -912,12 +916,13 @@ class MessageBubble extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: constrainedWidth,
|
||||
height: constrainedHeight,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildPhotoPlaceholder(
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, __, ___) => _buildPhotoPlaceholder(
|
||||
ctx,
|
||||
constrainedWidth,
|
||||
constrainedHeight,
|
||||
@@ -1016,12 +1021,13 @@ class MessageBubble extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, __, ___) =>
|
||||
_buildPhotoPlaceholder(ctx, 100, 100),
|
||||
)
|
||||
else
|
||||
@@ -1050,12 +1056,13 @@ class MessageBubble extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, __, ___) =>
|
||||
_buildPhotoPlaceholder(ctx, 100, 100),
|
||||
)
|
||||
else
|
||||
@@ -1279,12 +1286,13 @@ class MessageBubble extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
if (imageUrl.isNotEmpty)
|
||||
Image.network(
|
||||
imageUrl,
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, __, ___) =>
|
||||
_buildPhotoPlaceholder(ctx, 150, 150),
|
||||
)
|
||||
else
|
||||
@@ -1336,10 +1344,11 @@ class MessageBubble extends StatelessWidget {
|
||||
child: photoUrl != null && photoUrl.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.network(
|
||||
photoUrl,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photoUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Symbols.person,
|
||||
color: isMe ? Colors.white : cs.primary,
|
||||
size: 24,
|
||||
@@ -1438,7 +1447,7 @@ class MessageBubble extends StatelessWidget {
|
||||
if (senderAvatar != null && senderAvatar.isNotEmpty)
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundImage: NetworkImage(senderAvatar),
|
||||
backgroundImage: CachedNetworkImageProvider(senderAvatar),
|
||||
backgroundColor: cs.primaryContainer,
|
||||
)
|
||||
else
|
||||
@@ -1481,10 +1490,11 @@ class MessageBubble extends StatelessWidget {
|
||||
child: photoUrl != null && photoUrl.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.network(
|
||||
photoUrl,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: photoUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
fadeInDuration: const Duration(milliseconds: 120),
|
||||
errorWidget: (_, __, ___) => Icon(
|
||||
Symbols.person,
|
||||
color: isMe ? Colors.white : cs.primary,
|
||||
size: 24,
|
||||
@@ -1545,23 +1555,47 @@ class MessageBubble extends StatelessWidget {
|
||||
final textColor = isMe
|
||||
? Colors.white
|
||||
: (isDark ? cs.onSurface : const Color(0xFF1C1C1E));
|
||||
final payload = message.payload;
|
||||
final voice = payload?['voice'] as Map<String, dynamic>?;
|
||||
final duration = voice?['duration'] as int? ?? 0;
|
||||
final url = voice?['url']?.toString() ?? '';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_VoiceMessageBubble(
|
||||
duration: duration,
|
||||
url: url,
|
||||
textColor: textColor,
|
||||
isMe: isMe,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildMeta(context),
|
||||
],
|
||||
int duration = 0;
|
||||
String url = '';
|
||||
String? waveData;
|
||||
int? audioId;
|
||||
|
||||
final attaches = message.attachments;
|
||||
if (attaches != null && attaches.isNotEmpty) {
|
||||
for (final a in attaches) {
|
||||
if (a is AudioAttachment) {
|
||||
duration = ((a.duration ?? 0) / 1000).round();
|
||||
url = a.fileUrl ?? a.baseUrl ?? '';
|
||||
waveData = a.waveform;
|
||||
audioId = a.audioId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (duration == 0 && url.isEmpty) {
|
||||
final payload = message.payload;
|
||||
final voice = payload?['voice'] as Map<String, dynamic>?;
|
||||
duration = ((voice?['duration'] as int? ?? 0) / 1000).round();
|
||||
url = voice?['url']?.toString() ?? '';
|
||||
}
|
||||
|
||||
final cachedTranscription = TranscriptionCache.get(message.id);
|
||||
|
||||
return _VoiceMessageBubble(
|
||||
duration: duration,
|
||||
url: url,
|
||||
textColor: textColor,
|
||||
isMe: isMe,
|
||||
status: message.status,
|
||||
time: message.time,
|
||||
cs: cs,
|
||||
waveData: waveData,
|
||||
chatId: message.chatId,
|
||||
messageId: message.id,
|
||||
audioId: audioId,
|
||||
preloadedText: cachedTranscription?.text,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1661,12 +1695,28 @@ class _VoiceMessageBubble extends StatefulWidget {
|
||||
final String url;
|
||||
final Color textColor;
|
||||
final bool isMe;
|
||||
final String? status;
|
||||
final int time;
|
||||
final ColorScheme cs;
|
||||
final String? waveData;
|
||||
final int chatId;
|
||||
final String messageId;
|
||||
final int? audioId;
|
||||
final String? preloadedText;
|
||||
|
||||
const _VoiceMessageBubble({
|
||||
required this.duration,
|
||||
required this.url,
|
||||
required this.textColor,
|
||||
required this.isMe,
|
||||
this.status,
|
||||
required this.time,
|
||||
required this.cs,
|
||||
this.waveData,
|
||||
required this.chatId,
|
||||
required this.messageId,
|
||||
this.audioId,
|
||||
this.preloadedText,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -1676,97 +1726,305 @@ class _VoiceMessageBubble extends StatefulWidget {
|
||||
class _VoiceMessageBubbleState extends State<_VoiceMessageBubble> {
|
||||
bool _isPlaying = false;
|
||||
double _progress = 0.0;
|
||||
bool _transcriptionVisible = false;
|
||||
String? _transcriptionText;
|
||||
bool _transcriptionLoading = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.preloadedText != null) {
|
||||
_transcriptionText = widget.preloadedText;
|
||||
_transcriptionVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 220,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
String _formatDuration(int seconds) {
|
||||
final min = seconds ~/ 60;
|
||||
final sec = seconds % 60;
|
||||
return '$min:${sec.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _formatTime(int timestamp) {
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
|
||||
final hour = dt.hour.toString().padLeft(2, '0');
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon() {
|
||||
final status = widget.status;
|
||||
IconData icon;
|
||||
Color color;
|
||||
|
||||
if (status == null || status == 'sending' || status == 'pending') {
|
||||
icon = Symbols.check;
|
||||
color = Colors.white54;
|
||||
} else {
|
||||
switch (status) {
|
||||
case 'sent':
|
||||
icon = Symbols.check;
|
||||
color = Colors.white54;
|
||||
case 'delivered':
|
||||
icon = Symbols.done_all;
|
||||
color = Colors.white54;
|
||||
case 'read':
|
||||
icon = Symbols.done_all;
|
||||
color = const Color(0xFF34C759);
|
||||
case 'error':
|
||||
icon = Symbols.error;
|
||||
color = Colors.redAccent;
|
||||
default:
|
||||
icon = Symbols.check;
|
||||
color = Colors.white54;
|
||||
}
|
||||
}
|
||||
|
||||
return Icon(icon, size: 14, color: color);
|
||||
}
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = widget.cs.brightness == Brightness.dark;
|
||||
final waveInactiveColor = widget.isMe
|
||||
? Colors.white.withValues(alpha: 0.35)
|
||||
: (isDark
|
||||
? widget.cs.surfaceContainerHighest
|
||||
: const Color(0xFFD1D1D6));
|
||||
final waveActiveColor = widget.isMe
|
||||
? Colors.white.withValues(alpha: 0.7)
|
||||
: widget.cs.primary;
|
||||
|
||||
return SizedBox(
|
||||
width: 240,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _togglePlay,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isMe
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _togglePlay,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isMe
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: widget.cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isPlaying ? Symbols.pause : Symbols.play_arrow,
|
||||
color: widget.isMe ? Colors.white : widget.cs.primary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_isPlaying ? Symbols.pause : Symbols.play_arrow,
|
||||
color: widget.isMe ? Colors.white : cs.primary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isMe
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: (isDark
|
||||
? cs.surfaceContainerHighest
|
||||
: const Color(0xFFD1D1D6)),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
FractionallySizedBox(
|
||||
widthFactor: _progress.clamp(0.0, 1.0),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return GestureDetector(
|
||||
onTapDown: (details) {
|
||||
setState(() {
|
||||
_progress = (details.localPosition.dx /
|
||||
constraints.maxWidth)
|
||||
.clamp(0.0, 1.0);
|
||||
});
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
setState(() {
|
||||
_progress = (details.localPosition.dx /
|
||||
constraints.maxWidth)
|
||||
.clamp(0.0, 1.0);
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
height: 24,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isMe
|
||||
? Colors.white.withValues(alpha: 0.5)
|
||||
: cs.primary,
|
||||
color: waveInactiveColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_formatDuration(widget.duration),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: _progress.clamp(0.0, 1.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: waveActiveColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _requestTranscription,
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 32,
|
||||
child: Center(
|
||||
child: _transcriptionLoading
|
||||
? SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
color: widget.textColor.withValues(alpha: 0.6),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Т',
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.6),
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(widget.duration),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.7),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.topLeft,
|
||||
child: _transcriptionVisible
|
||||
? Text(
|
||||
_transcriptionText ?? '',
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.8),
|
||||
fontSize: 12,
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 10,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (!_transcriptionVisible) ...[
|
||||
Text(
|
||||
_formatTime(widget.time),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
if (widget.isMe) ...[
|
||||
const SizedBox(width: 2),
|
||||
_buildStatusIcon(),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
if (_transcriptionVisible) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(widget.time),
|
||||
style: TextStyle(
|
||||
color: widget.textColor.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
if (widget.isMe) ...[
|
||||
const SizedBox(width: 2),
|
||||
_buildStatusIcon(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressBar(Color inactive, Color active) {
|
||||
return Container(
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: inactive,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _togglePlay() {
|
||||
setState(() {
|
||||
_isPlaying = !_isPlaying;
|
||||
});
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final min = seconds ~/ 60;
|
||||
final sec = seconds % 60;
|
||||
return '$min:${sec.toString().padLeft(2, '0')}';
|
||||
Future<void> _requestTranscription() async {
|
||||
if (widget.audioId == null) return;
|
||||
|
||||
if (_transcriptionVisible && _transcriptionText != null) {
|
||||
setState(() {
|
||||
_transcriptionVisible = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (TranscriptionCache.has(widget.messageId)) {
|
||||
final cached = TranscriptionCache.get(widget.messageId)!;
|
||||
setState(() {
|
||||
_transcriptionText = cached.text ?? 'не удалось распознать текст';
|
||||
_transcriptionVisible = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_transcriptionLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await messagesModule.requestTranscription(
|
||||
widget.chatId,
|
||||
int.tryParse(widget.messageId) ?? 0,
|
||||
widget.audioId!,
|
||||
);
|
||||
|
||||
TranscriptionCache.put(widget.messageId, result);
|
||||
|
||||
setState(() {
|
||||
_transcriptionLoading = false;
|
||||
if (result.status == 1) {
|
||||
_transcriptionText = (result.text == null || result.text!.isEmpty)
|
||||
? 'не удалось распознать текст'
|
||||
: result.text;
|
||||
_transcriptionVisible = true;
|
||||
} else if (result.status == 0) {
|
||||
_transcriptionText = 'транскрибация...';
|
||||
_transcriptionVisible = true;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_transcriptionLoading = false;
|
||||
_transcriptionText = 'ошибка транскрибации';
|
||||
_transcriptionVisible = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -159,5 +159,10 @@
|
||||
"chatInfoJoined": "joined:",
|
||||
"chatInfoGroupCreated": "group created:",
|
||||
"chatInfoGroupOwner": "group owner:",
|
||||
"chatInfoDialogStarted": "dialog started:"
|
||||
"chatInfoDialogStarted": "dialog started:",
|
||||
"editProfileTitle": "Edit Profile",
|
||||
"editProfileSave": "Save",
|
||||
"editProfileFirstName": "First name",
|
||||
"editProfileLastName": "Last name",
|
||||
"editProfileRemovePhoto": "Remove photo"
|
||||
}
|
||||
|
||||
@@ -955,6 +955,36 @@ abstract class AppLocalizations {
|
||||
/// In en, this message translates to:
|
||||
/// **'dialog started:'**
|
||||
String get chatInfoDialogStarted;
|
||||
|
||||
/// No description provided for @editProfileTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Edit Profile'**
|
||||
String get editProfileTitle;
|
||||
|
||||
/// No description provided for @editProfileSave.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get editProfileSave;
|
||||
|
||||
/// No description provided for @editProfileFirstName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'First name'**
|
||||
String get editProfileFirstName;
|
||||
|
||||
/// No description provided for @editProfileLastName.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last name'**
|
||||
String get editProfileLastName;
|
||||
|
||||
/// No description provided for @editProfileRemovePhoto.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove photo'**
|
||||
String get editProfileRemovePhoto;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -451,4 +451,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get chatInfoDialogStarted => 'dialog started:';
|
||||
|
||||
@override
|
||||
String get editProfileTitle => 'Edit Profile';
|
||||
|
||||
@override
|
||||
String get editProfileSave => 'Save';
|
||||
|
||||
@override
|
||||
String get editProfileFirstName => 'First name';
|
||||
|
||||
@override
|
||||
String get editProfileLastName => 'Last name';
|
||||
|
||||
@override
|
||||
String get editProfileRemovePhoto => 'Remove photo';
|
||||
}
|
||||
|
||||
@@ -453,4 +453,19 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get chatInfoDialogStarted => 'ЛС начат в:';
|
||||
|
||||
@override
|
||||
String get editProfileTitle => 'Редактирование профиля';
|
||||
|
||||
@override
|
||||
String get editProfileSave => 'Сохранить';
|
||||
|
||||
@override
|
||||
String get editProfileFirstName => 'Имя';
|
||||
|
||||
@override
|
||||
String get editProfileLastName => 'Фамилия';
|
||||
|
||||
@override
|
||||
String get editProfileRemovePhoto => 'Удалить фото';
|
||||
}
|
||||
|
||||
+6
-1
@@ -159,5 +159,10 @@
|
||||
"chatInfoJoined": "Зашли в:",
|
||||
"chatInfoGroupCreated": "Группа создана в:",
|
||||
"chatInfoGroupOwner": "Создатель группы:",
|
||||
"chatInfoDialogStarted": "ЛС начат в:"
|
||||
"chatInfoDialogStarted": "ЛС начат в:",
|
||||
"editProfileTitle": "Редактирование профиля",
|
||||
"editProfileSave": "Сохранить",
|
||||
"editProfileFirstName": "Имя",
|
||||
"editProfileLastName": "Фамилия",
|
||||
"editProfileRemovePhoto": "Удалить фото"
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ class KometAppState extends State<KometApp> {
|
||||
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
||||
widget.initialFpsOverlay,
|
||||
);
|
||||
final _profileUpdateController = StreamController<void>.broadcast();
|
||||
Stream<void> get profileUpdateStream => _profileUpdateController.stream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -128,6 +130,7 @@ class KometAppState extends State<KometApp> {
|
||||
@override
|
||||
void dispose() {
|
||||
_sessionExpiredSub?.cancel();
|
||||
_profileUpdateController.close();
|
||||
fpsOverlayEnabled.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -152,6 +155,10 @@ class KometAppState extends State<KometApp> {
|
||||
}
|
||||
}
|
||||
|
||||
void notifyProfileUpdate() {
|
||||
_profileUpdateController.add(null);
|
||||
}
|
||||
|
||||
ColorScheme _adjustDarkScheme(ColorScheme base) {
|
||||
return base.copyWith(
|
||||
surface: Color.alphaBlend(
|
||||
|
||||
@@ -198,14 +198,27 @@ class AudioAttachment extends MessageAttachment {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String? waveStr;
|
||||
final waveRaw = map['wave'];
|
||||
if (waveRaw is String) {
|
||||
waveStr = waveRaw;
|
||||
} else if (waveRaw is List) {
|
||||
try {
|
||||
final bytes = List<int>.from(waveRaw);
|
||||
final base64 = String.fromCharCodes(bytes);
|
||||
waveStr = 'data:image/webp;base64,$base64';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return AudioAttachment(
|
||||
previewData: previewStr,
|
||||
baseUrl: map['baseUrl'] as String?,
|
||||
baseUrl: map['baseUrl']?.toString(),
|
||||
fileUrl: map['url']?.toString(),
|
||||
audioId: map['audioId'] as int?,
|
||||
audioToken: map['audioToken'] as String?,
|
||||
audioToken: map['token']?.toString(),
|
||||
duration: map['duration'] as int?,
|
||||
size: map['size'] as int?,
|
||||
waveform: map['waveform'] as String?,
|
||||
waveform: waveStr,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -306,7 +319,7 @@ class StickerAttachment extends MessageAttachment {
|
||||
previewData: previewStr,
|
||||
baseUrl: (map['url'] ?? map['baseUrl'])?.toString(),
|
||||
stickerId: map['stickerId']?.toString(),
|
||||
stickerPackId: (map['stickerPackId'] ?? map['setId'])?.toString(),
|
||||
stickerPackId: map['setId']?.toString() ?? map['stickerPackId']?.toString(),
|
||||
width: map['width'] as int?,
|
||||
height: map['height'] as int?,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cached_network_image
|
||||
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
cached_network_image_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_platform_interface
|
||||
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
cached_network_image_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image_web
|
||||
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -145,11 +169,27 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
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_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -389,6 +429,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: octo_image
|
||||
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -485,6 +533,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -674,6 +730,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -54,6 +54,7 @@ dependencies:
|
||||
shared_preferences: ^2.5.4
|
||||
package_info_plus: ^9.0.1
|
||||
mobile_scanner: ^7.2.0
|
||||
cached_network_image: ^3.4.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user