This commit is contained in:
Jganenokk
2026-06-28 19:00:52 +07:00
parent b545d4ec41
commit a48652ef63
5 changed files with 432 additions and 38 deletions
+110 -18
View File
@@ -53,6 +53,7 @@ class CallLogEntry {
final CallStatus status;
final int time;
final int count;
final bool isGroup;
const CallLogEntry({
required this.id,
@@ -63,6 +64,7 @@ class CallLogEntry {
required this.status,
required this.time,
this.count = 1,
this.isGroup = false,
});
}
@@ -197,22 +199,58 @@ class CallsModule {
if (!response.isOk || response.payload is! Map) return [];
final payload = response.payload as Map<dynamic, dynamic>;
return parseHistoryPayload(payload, accountId, currentUserId);
return parseHistoryPayload(
payload,
accountId,
currentUserId,
resolver: resolveContacts,
);
}
Future<Map<int, Map<String, dynamic>>> resolveContacts(List<int> ids) async {
if (ids.isEmpty) return const {};
final out = <int, Map<String, dynamic>>{};
try {
final resp = await _api.sendRequest(Opcode.contactInfo, {
'contactIds': ids,
});
final data = resp.payload;
final contacts = data is Map ? data['contacts'] : null;
if (contacts is List) {
for (final c in contacts) {
if (c is Map) {
final id = c['id'];
if (id is int) out[id] = Map<String, dynamic>.from(c);
}
}
}
} catch (_) {}
return out;
}
Future<bool> deleteHistory(List<int> historyIds) async {
if (historyIds.isEmpty) return true;
final response = await _api.sendRequest(Opcode.videoChatDeleteHistory, {
'historyIds': historyIds,
});
return response.isOk;
}
/// Парсинг истории звонков (opcode 79: videoChatHistory)
static Future<List<CallLogEntry>> parseHistoryPayload(
Map<dynamic, dynamic> payload,
int accountId,
int currentUserId,
) async {
int currentUserId, {
Future<Map<int, Map<String, dynamic>>> Function(List<int> ids)? resolver,
}) async {
final history = payload['history'];
if (history is! List || history.isEmpty) return [];
final recentContacts = await ContactsModule.getContacts(accountId);
final contactsMap = {for (final c in recentContacts) c.id: c};
final List<CallLogEntry> extractedCalls = [];
final parsed =
<({int peerId, CallStatus status, int time, String id})>[];
for (final item in history.whereType<Map>()) {
final msg = item['message'];
@@ -241,26 +279,63 @@ class CallsModule {
peerId = senderId;
}
final contact = contactsMap[peerId];
final status = _parseCallStatus(callAttach, isOutgoing);
final time = (msg['time'] as int?) ?? 0;
final msgId =
msg['id']?.toString() ??
DateTime.now().millisecondsSinceEpoch.toString();
parsed.add((
peerId: peerId,
status: _parseCallStatus(callAttach, isOutgoing),
time: (msg['time'] as int?) ?? 0,
id:
msg['id']?.toString() ??
DateTime.now().millisecondsSinceEpoch.toString(),
));
}
final name = (contact != null && contact.firstName.isNotEmpty)
? '${contact.firstName} ${contact.lastName ?? ''}'.trim()
: 'Неизвестный';
bool localResolved(int id) {
final c = contactsMap[id];
return c != null && c.firstName.isNotEmpty;
}
final unresolvedIds = parsed
.map((e) => e.peerId)
.where((id) => id != 0 && !localResolved(id))
.toSet()
.toList();
var fetched = const <int, Map<String, dynamic>>{};
if (unresolvedIds.isNotEmpty && resolver != null) {
fetched = await resolver(unresolvedIds);
}
final List<CallLogEntry> extractedCalls = [];
for (final e in parsed) {
final contact = contactsMap[e.peerId];
String name;
String? avatarUrl;
bool isGroup = false;
if (contact != null && contact.firstName.isNotEmpty) {
name = '${contact.firstName} ${contact.lastName ?? ''}'.trim();
avatarUrl = contact.baseUrl;
} else {
final info = fetched[e.peerId];
final resolved = _nameFromInfo(info);
if (resolved != null) {
name = resolved;
avatarUrl = (info?['baseUrl'] as String?) ?? contact?.baseUrl;
} else {
name = 'Групповой звонок';
isGroup = true;
}
}
extractedCalls.add(
CallLogEntry(
id: msgId,
id: e.id,
accountId: accountId,
peerId: peerId,
peerId: e.peerId,
name: name,
avatarUrl: contact?.baseUrl,
status: status,
time: time,
avatarUrl: avatarUrl,
status: e.status,
time: e.time,
isGroup: isGroup,
),
);
}
@@ -268,6 +343,23 @@ class CallsModule {
return extractedCalls;
}
static String? _nameFromInfo(Map<String, dynamic>? info) {
if (info == null) return null;
final names = info['names'];
if (names is List && names.isNotEmpty) {
final n = names.first;
if (n is Map) {
final full = n['name']?.toString();
if (full != null && full.isNotEmpty) return full;
final first = n['firstName']?.toString() ?? '';
final last = n['lastName']?.toString() ?? '';
final combined = '$first $last'.trim();
if (combined.isNotEmpty) return combined;
}
}
return null;
}
static CallStatus _parseCallStatus(
Map<dynamic, dynamic> callAttach,
bool isOutgoing,
+2
View File
@@ -106,6 +106,7 @@ abstract class Opcode {
static const int chatMembersUpdate = 77; // Обновление участников / добавление
static const int videoChatStartActive = 78; // Инициация активного звонка
static const int videoChatHistory = 79; // История звонков
static const int videoChatDeleteHistory = 164; // Удаление записей истории звонков
static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат
static const int videoChatJoinByLink = 166; // Вход в звонок по ссылке
static const int videoChatMembers = 195; // Участники видеочата
@@ -288,6 +289,7 @@ abstract class Opcode {
chatMembersUpdate: 'CHAT_MEMBERS_UPDATE',
videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE',
videoChatHistory: 'VIDEO_CHAT_HISTORY',
videoChatDeleteHistory: 'VIDEO_CHAT_DELETE_HISTORY',
videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK',
videoChatJoinByLink: 'VIDEO_CHAT_JOIN_BY_LINK',
videoChatMembers: 'VIDEO_CHAT_MEMBERS',
+128 -15
View File
@@ -6,10 +6,13 @@ import '../../../main.dart' show api, accountModule;
import '../../../backend/modules/account.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/format.dart';
import '../../../core/calls/call_controller.dart';
import '../../../backend/modules/calls.dart';
import '../../widgets/komet_avatar.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/custom_notification.dart';
import '../../widgets/chat_menu_overlay.dart';
import 'call_screen.dart';
class CallsTab extends StatefulWidget {
const CallsTab({super.key});
@@ -20,6 +23,7 @@ class CallsTab extends StatefulWidget {
class _CallsTabState extends State<CallsTab> {
List<CallLogEntry> _calls = [];
final Set<String> _removing = {};
bool _isLoading = true;
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
StreamSubscription<LoginStatus>? _loginSub;
@@ -79,6 +83,7 @@ class _CallsTabState extends State<CallsTab> {
status: last.status,
time: last.time,
count: last.count + 1,
isGroup: last.isGroup,
),
);
} else {
@@ -154,16 +159,23 @@ class _CallsTabState extends State<CallsTab> {
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: call.isGroup ? cs.primaryContainer : null,
border: Border.all(
color: cs.primary.withValues(alpha: 0.1),
width: 1,
),
),
child: KometAvatar(
name: call.name,
imageUrl: call.avatarUrl,
size: 48,
),
child: call.isGroup
? Icon(
Symbols.groups,
color: cs.onPrimaryContainer,
size: 26,
)
: KometAvatar(
name: call.name,
imageUrl: call.avatarUrl,
size: 48,
),
),
const SizedBox(width: 16),
Expanded(
@@ -244,17 +256,69 @@ class _CallsTabState extends State<CallsTab> {
icon: Symbols.delete,
label: 'Удалить',
destructive: true,
onTap: () {},
),
ChatMenuItem(
icon: Symbols.call,
label: 'Перезвонить',
onTap: () {},
onTap: () => _deleteCall(call),
),
if (!call.isGroup)
ChatMenuItem(
icon: Symbols.call,
label: 'Перезвонить',
onTap: () => _callBack(call),
),
],
);
}
Future<void> _deleteCall(CallLogEntry call) async {
setState(() => _removing.add(call.id));
final historyId = int.tryParse(call.id);
if (historyId != null) {
unawaited(CallsModule(api).deleteHistory([historyId]));
}
await Future.delayed(const Duration(milliseconds: 260));
if (!mounted) return;
setState(() {
_calls.removeWhere((c) => c.id == call.id);
_removing.remove(call.id);
});
}
Future<void> _callBack(CallLogEntry call) async {
if (call.peerId <= 0) {
showCustomNotification(context, 'Не удалось определить собеседника');
return;
}
final navigator = Navigator.of(context);
final avatarUrl = (call.avatarUrl?.isNotEmpty ?? false)
? call.avatarUrl
: null;
final active = CallController.instance.activeSession;
if (active != null) {
await navigator.push(
MaterialPageRoute(
builder: (_) =>
CallScreen(name: call.name, avatarUrl: avatarUrl, session: active),
),
);
return;
}
try {
final session = await CallController.instance.startOutgoing(call.peerId);
if (!mounted) return;
await navigator.push(
MaterialPageRoute(
builder: (_) => CallScreen(
name: call.name,
avatarUrl: avatarUrl,
session: session,
),
),
);
} catch (_) {
if (!mounted) return;
showCustomNotification(context, 'Не удалось начать звонок');
}
}
Widget _buildTabItem(String label, int index, ColorScheme cs) {
final isSelected = _selectedTabIndex == index;
return GestureDetector(
@@ -369,10 +433,11 @@ class _CallsTabState extends State<CallsTab> {
padding: const EdgeInsets.only(bottom: 120),
itemCount: filteredCalls.length,
itemBuilder: (context, index) {
return _buildCallItem(
context,
cs,
filteredCalls[index],
final call = filteredCalls[index];
return _RemovableCallEntry(
key: ValueKey(call.id),
removing: _removing.contains(call.id),
child: _buildCallItem(context, cs, call),
);
},
),
@@ -383,3 +448,51 @@ class _CallsTabState extends State<CallsTab> {
);
}
}
class _RemovableCallEntry extends StatefulWidget {
final bool removing;
final Widget child;
const _RemovableCallEntry({
required Key key,
required this.removing,
required this.child,
}) : super(key: key);
@override
State<_RemovableCallEntry> createState() => _RemovableCallEntryState();
}
class _RemovableCallEntryState extends State<_RemovableCallEntry>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
value: 1.0,
);
late final Animation<double> _animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
@override
void didUpdateWidget(covariant _RemovableCallEntry oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.removing && !oldWidget.removing) _controller.reverse();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _animation,
alignment: Alignment.topCenter,
child: FadeTransition(opacity: _animation, child: widget.child),
);
}
}
@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -1394,6 +1396,12 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
),
),
),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 12, 16, 0),
child: _SyncProbeCard(),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 120)),
],
),
@@ -1402,6 +1410,178 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
}
}
class _SyncProbeCard extends StatefulWidget {
const _SyncProbeCard();
@override
State<_SyncProbeCard> createState() => _SyncProbeCardState();
}
class _SyncProbeCardState extends State<_SyncProbeCard> {
final _phoneController = TextEditingController();
final _nameController = TextEditingController();
bool _loading = false;
String? _result;
@override
void dispose() {
_phoneController.dispose();
_nameController.dispose();
super.dispose();
}
Future<void> _send() async {
final phone = _phoneController.text.trim();
final name = _nameController.text.trim();
if (phone.isEmpty) {
setState(() => _result = 'Введите номер');
return;
}
setState(() {
_loading = true;
_result = null;
});
try {
final packet = await api.sendRequest(Opcode.sync, {
'contactList': {
phone: {'firstName': name},
},
});
if (!mounted) return;
setState(() {
_loading = false;
_result = _pretty(packet.payload);
});
} on PacketError catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_result = 'PacketError: ${e.message}';
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_result = 'Ошибка: $e';
});
}
}
String _pretty(dynamic payload) {
const encoder = JsonEncoder.withIndent(' ');
try {
return encoder.convert(_jsonSafe(payload));
} catch (_) {
return payload.toString();
}
}
dynamic _jsonSafe(dynamic v) {
if (v is Map) {
return v.map((k, val) => MapEntry(k.toString(), _jsonSafe(val)));
}
if (v is List) return v.map(_jsonSafe).toList();
if (v is String || v is num || v is bool || v == null) return v;
return v.toString();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GlossyPill(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20),
depth: 6,
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sync contactList (21)',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'Резолв контакта по номеру и имени, полный ответ сервера',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 12),
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
enabled: !_loading,
decoration: InputDecoration(
hintText: '+6282233831826',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
),
const SizedBox(height: 10),
TextField(
controller: _nameController,
enabled: !_loading,
decoration: InputDecoration(
hintText: 'Имя',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _loading ? null : _send,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Отправить'),
),
if (_result != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: cs.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: SelectableText(
_result!,
style: TextStyle(
color: cs.onSurface,
fontSize: 12,
fontFamily: 'monospace',
),
),
),
],
],
),
);
}
}
enum _HitKind { dialog, chat, channel, bot, official, contact, user, unknown }
class _SearchHit {
@@ -154,8 +154,15 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer>
final totalItems = _accounts.length;
final height = _vPad * 2 + totalItems * _itemHeight + _addItemHeight;
double menuX = widget.tapPoint.dx - _menuWidth / 2;
menuX = menuX.clamp(_hMargin, screen.width - _menuWidth - _hMargin);
final maxWidth = screen.width - 2 * _hMargin;
final menuWidth = maxWidth <= 0
? screen.width
: (_menuWidth > maxWidth ? maxWidth : _menuWidth);
double menuX = widget.tapPoint.dx - menuWidth / 2;
final maxX = screen.width - menuWidth - _hMargin;
if (menuX > maxX) menuX = maxX;
if (menuX < _hMargin) menuX = _hMargin;
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
final maxBottom = screen.height - bottomInset - 88;
@@ -166,19 +173,19 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer>
double menuY = menuBottom - height;
if (menuY < 24) menuY = 24;
_menuRect = Rect.fromLTWH(menuX, menuY, _menuWidth, height);
_menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, height);
_itemHitRects = [
for (int i = 0; i < totalItems; i++)
Rect.fromLTWH(
menuX,
menuY + _vPad + i * _itemHeight,
_menuWidth,
menuWidth,
_itemHeight,
),
Rect.fromLTWH(
menuX,
menuY + _vPad + totalItems * _itemHeight,
_menuWidth,
menuWidth,
_addItemHeight,
),
];