From ac39a374c30642078e3536aef63aa5a5de24845a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B0=D1=88=D0=B5=20=D0=98=D0=BC=D1=8F?= Date: Sat, 4 Apr 2026 22:05:50 +0700 Subject: [PATCH] =?UTF-8?q?=D1=83=20=D0=BC=D0=B5=D0=BD=D1=8F=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/calls.dart | 147 ++++++++ lib/backend/modules/contacts.dart | 41 +++ lib/frontend/screens/calls/calls_tab.dart | 345 +++++++++++++++++- .../screens/contacts/contacts_tab.dart | 194 +++++++++- 4 files changed, 709 insertions(+), 18 deletions(-) diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index e69de29..2182af0 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -0,0 +1,147 @@ +// Backend module for parsing calls from Komet platform +import '../../core/storage/app_database.dart'; +import 'contacts.dart'; +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; + +enum CallStatus { missed, canceled, outgoing, incoming } + +class CallLogEntry { + final String id; + final int accountId; + final int peerId; + final String name; + final String? avatarUrl; + final CallStatus status; + final int time; + final int count; + + const CallLogEntry({ + required this.id, + required this.accountId, + required this.peerId, + required this.name, + this.avatarUrl, + required this.status, + required this.time, + this.count = 1, + }); +} + +class CallsModule { + final Api _api; + + CallsModule(this._api); + + /// Fetch call history from opcode 79 + Future> fetchHistory( + int accountId, + int currentUserId, + ) async { + final response = await _api.sendRequest(Opcode.videoChatHistory, {}); + if (!response.isOk || response.payload is! Map) return []; + + final payload = response.payload as Map; + return parseHistoryPayload(payload, accountId, currentUserId); + } + + /// Парсинг истории звонков (opcode 79: videoChatHistory) + static Future> parseHistoryPayload( + Map payload, + int accountId, + int currentUserId, + ) 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}; + + print('DEBUG: Loaded ${recentContacts.length} contacts'); + print('DEBUG: Contact IDs: ${contactsMap.keys.toList()}'); + print('DEBUG: Current user ID: $currentUserId'); + + final List extractedCalls = []; + + for (final item in history.whereType()) { + final msg = item['message']; + if (msg is! Map) continue; + + final attaches = msg['attaches']; + if (attaches is! List || attaches.isEmpty) continue; + + final callAttach = attaches.firstWhere( + (a) => a is Map && a['_type'] == 'CALL', + orElse: () => null, + ); + + if (callAttach == null) continue; + + final senderId = (msg['sender'] as int?) ?? 0; + final isOutgoing = senderId == currentUserId; + + int peerId = 0; + if (isOutgoing) { + final contactIds = callAttach['contactIds']; + if (contactIds is List && contactIds.isNotEmpty) { + peerId = (contactIds.first as int?) ?? 0; + } + } else { + peerId = senderId; + } + + print( + 'DEBUG: Call - isOutgoing: $isOutgoing, peerId: $peerId, senderId: $senderId', + ); + final contact = contactsMap[peerId]; + print( + 'DEBUG: Contact found: ${contact != null}, firstName: "${contact?.firstName}", lastName: "${contact?.lastName}"', + ); + final status = _parseCallStatus(callAttach, isOutgoing); + final time = (msg['time'] as int?) ?? 0; + final msgId = + msg['id']?.toString() ?? + DateTime.now().millisecondsSinceEpoch.toString(); + + final name = contact?.firstName != null + ? '${contact!.firstName} ${contact.lastName ?? ''}'.trim() + : 'Неизвестный'; + + print('DEBUG: Creating CallLogEntry with name: "$name"'); + + extractedCalls.add( + CallLogEntry( + id: msgId, + accountId: accountId, + peerId: peerId, + name: name, + avatarUrl: contact?.baseUrl, + status: status, + time: time, + ), + ); + } + + return extractedCalls; + } + + static CallStatus _parseCallStatus( + Map callAttach, + bool isOutgoing, + ) { + final hangupType = callAttach['hangupType']; + final duration = (callAttach['duration'] as int?) ?? 0; + + if (isOutgoing) { + if (hangupType == 'CANCELED' || duration == 0) return CallStatus.canceled; + return CallStatus.outgoing; + } else { + if (hangupType == 'CANCELED' || + hangupType == 'REJECTED' || + hangupType == 'MISSED' || + duration == 0) + return CallStatus.missed; + return CallStatus.incoming; + } + } +} diff --git a/lib/backend/modules/contacts.dart b/lib/backend/modules/contacts.dart index 7d6614a..1943e52 100644 --- a/lib/backend/modules/contacts.dart +++ b/lib/backend/modules/contacts.dart @@ -1,5 +1,41 @@ import '../../core/storage/app_database.dart'; +class CachedContact { + final int id; + final int accountId; + final String firstName; + final String? lastName; + final int phone; + final int? photoId; + final String? baseUrl; + final String? baseRawUrl; + final int updateTime; + + const CachedContact({ + required this.id, + required this.accountId, + required this.firstName, + this.lastName, + required this.phone, + this.photoId, + this.baseUrl, + this.baseRawUrl, + required this.updateTime, + }); + + factory CachedContact.fromDbRow(Map row) => CachedContact( + id: row['id'] as int, + accountId: row['account_id'] as int, + firstName: row['first_name'] as String, + lastName: row['last_name'] as String?, + phone: row['phone'] as int, + photoId: row['photo_id'] as int?, + baseUrl: row['base_url'] as String?, + baseRawUrl: row['base_raw_url'] as String?, + updateTime: row['update_time'] as int, + ); +} + class ContactsModule { static Future syncFromLoginPayload( Map data, @@ -19,6 +55,11 @@ class ContactsModule { } } + static Future> getContacts(int accountId) async { + final rows = await AppDatabase.loadContacts(accountId); + return rows.map(CachedContact.fromDbRow).toList(); + } + static Map? _parseContact( Map contact, int accountId, diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 21bf27b..241bc6a 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -1,19 +1,346 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../main.dart' show api; +import '../../../core/storage/app_database.dart'; +import '../../../backend/modules/calls.dart'; -class CallsTab extends StatelessWidget { +class CallsTab extends StatefulWidget { const CallsTab({super.key}); + @override + State createState() => _CallsTabState(); +} + +class _CallsTabState extends State { + List _calls = []; + bool _isLoading = true; + int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные' + + @override + void initState() { + super.initState(); + _loadHistory(); + } + + Future _loadHistory() async { + final p = await AppDatabase.loadActiveProfile(); + if (p == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + + final callsModule = CallsModule(api); + final calls = await callsModule.fetchHistory(p.id, p.id); + + print('DEBUG UI: Received ${calls.length} calls'); + for (final call in calls.take(3)) { + print('DEBUG UI: Call name="${call.name}", peerId=${call.peerId}'); + } + + // Группируем подряд идущие звонки одному и тому же абоненту в один день с одним и тем же статусом + final List grouped = []; + for (final call in calls) { + if (grouped.isNotEmpty && + grouped.last.peerId == call.peerId && + grouped.last.status == call.status && + _isSameDay(grouped.last.time, call.time)) { + final last = grouped.removeLast(); + grouped.add( + CallLogEntry( + id: last.id, + accountId: last.accountId, + peerId: last.peerId, + name: last.name, + avatarUrl: last.avatarUrl, + status: last.status, + time: last.time, + count: last.count + 1, + ), + ); + } else { + grouped.add(call); + } + } + + if (mounted) { + setState(() { + _calls = grouped; + _isLoading = false; + }); + } + } + + bool _isSameDay(int time1, int time2) { + if (time1 == 0 || time2 == 0) return false; + final d1 = DateTime.fromMillisecondsSinceEpoch(time1); + final d2 = DateTime.fromMillisecondsSinceEpoch(time2); + return d1.year == d2.year && d1.month == d2.month && d1.day == d2.day; + } + + String _formatDate(int timestamp) { + if (timestamp == 0) return ''; + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final months = [ + 'янв.', + 'фев.', + 'мар.', + 'апр.', + 'мая', + 'июн.', + 'июл.', + 'авг.', + 'сен.', + 'окт.', + 'ноя.', + 'дек.', + ]; + return '${dt.day} ${months[dt.month - 1]}'; + } + + Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildCallItem( + BuildContext context, + ColorScheme cs, + CallLogEntry call, + ) { + final bool isMissed = call.status == CallStatus.missed; + + String statusText; + IconData statusIcon; + switch (call.status) { + case CallStatus.missed: + statusText = 'Пропущенный'; + statusIcon = Symbols.phone_missed; + break; + case CallStatus.canceled: + statusText = 'Отменённый'; + statusIcon = Symbols.phone_disabled; + break; + case CallStatus.outgoing: + statusText = 'Исходящий'; + statusIcon = Symbols.call_made; + break; + case CallStatus.incoming: + statusText = 'Входящий'; + statusIcon = Symbols.call_received; + break; + } + + final String displayName = call.count > 1 + ? '${call.name} (${call.count})' + : call.name; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Open call details or initiate call + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.1), + width: 1, + ), + ), + child: ClipOval( + child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty + ? Image.network( + call.avatarUrl!, + fit: BoxFit.cover, + errorBuilder: (context, _, ___) => + _buildPlaceholderAvatar(cs, call.name), + ) + : _buildPlaceholderAvatar(cs, call.name), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: TextStyle( + color: isMissed ? cs.error : cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(statusIcon, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + _formatDate(call.time), + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.7), + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTabItem(String label, int index, ColorScheme cs) { + final isSelected = _selectedTabIndex == index; + return GestureDetector( + onTap: () { + setState(() { + _selectedTabIndex = index; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? cs.primary : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? cs.primary : cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Center( - child: Text( - 'Звонки (Заглушка)', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w500, + + final filteredCalls = _selectedTabIndex == 1 + ? _calls.where((c) => c.status == CallStatus.missed).toList() + : _calls; + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + 'Звонки', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + InkWell( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + child: Row( + children: [ + Icon(Symbols.link, color: cs.primary, size: 24), + const SizedBox(width: 16), + Text( + 'Создать групповой звонок', + style: TextStyle( + color: cs.primary, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + child: Row( + children: [ + _buildTabItem('Все', 0, cs), + const SizedBox(width: 8), + _buildTabItem('Пропущенные', 1, cs), + ], + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : filteredCalls.isEmpty + ? Center( + child: Text( + 'Нет звонков', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 120), + itemCount: filteredCalls.length, + itemBuilder: (context, index) { + return _buildCallItem( + context, + cs, + filteredCalls[index], + ); + }, + ), + ), + ], ), ), ); diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index c47b562..fffa416 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -1,19 +1,195 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../../../core/storage/app_database.dart'; +import '../../../backend/modules/contacts.dart'; -class ContactsTab extends StatelessWidget { +class ContactsTab extends StatefulWidget { const ContactsTab({super.key}); + @override + State createState() => _ContactsTabState(); +} + +class _ContactsTabState extends State { + List _contacts = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadContacts(); + } + + Future _loadContacts() async { + final p = await AppDatabase.loadActiveProfile(); + if (p == null) { + if (mounted) setState(() => _isLoading = false); + return; + } + final contacts = await ContactsModule.getContacts(p.id); + // Sort contacts by first name + contacts.sort((a, b) => a.firstName.compareTo(b.firstName)); + if (mounted) { + setState(() { + _contacts = contacts; + _isLoading = false; + }); + } + } + + Widget _buildPlaceholderAvatar(ColorScheme cs, String name) { + return Container( + color: cs.primaryContainer, + alignment: Alignment.center, + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildContactItem( + BuildContext context, + ColorScheme cs, + CachedContact contact, + ) { + final fullName = + '${contact.firstName}${contact.lastName != null ? ' ${contact.lastName}' : ''}' + .trim(); + final nameToDisplay = fullName.isEmpty ? '+${contact.phone}' : fullName; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + // Open contact details or chat + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.1), + width: 1, + ), + ), + child: ClipOval( + child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty + ? Image.network( + contact.baseUrl!, + fit: BoxFit.cover, + errorBuilder: (context, _, ___) => + _buildPlaceholderAvatar(cs, nameToDisplay), + ) + : _buildPlaceholderAvatar(cs, nameToDisplay), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nameToDisplay, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + contact.updateTime > 0 + ? 'Был(а) недавно' + : '+${contact.phone}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - return Center( - child: Text( - 'Контакты (Заглушка)', - style: GoogleFonts.inter( - color: cs.onSurface, - fontSize: 20, - fontWeight: FontWeight.w500, + + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + bottom: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'Контакты', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + ), + IconButton( + icon: Icon(Symbols.person_add, color: cs.onSurface), + onPressed: () {}, + ), + IconButton( + icon: Icon(Symbols.search, color: cs.onSurface), + onPressed: () {}, + ), + ], + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _contacts.isEmpty + ? Center( + child: Text( + 'Нет контактов', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + ), + ), + ) + : ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only(bottom: 120), + itemCount: _contacts.length, + itemBuilder: (context, index) { + final contact = _contacts[index]; + return _buildContactItem(context, cs, contact); + }, + ), + ), + ], ), ), );