у меня деменция

This commit is contained in:
Jganenokk
2026-04-04 22:08:47 +07:00
parent 546ab8de19
commit 87f9d6aeb8
4 changed files with 709 additions and 18 deletions
+147
View File
@@ -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<List<CallLogEntry>> 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<dynamic, dynamic>;
return parseHistoryPayload(payload, accountId, currentUserId);
}
/// Парсинг истории звонков (opcode 79: videoChatHistory)
static Future<List<CallLogEntry>> parseHistoryPayload(
Map<dynamic, dynamic> 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<CallLogEntry> extractedCalls = [];
for (final item in history.whereType<Map>()) {
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<dynamic, dynamic> 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;
}
}
}
+41
View File
@@ -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<String, dynamic> 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<void> syncFromLoginPayload(
Map<dynamic, dynamic> data,
@@ -19,6 +55,11 @@ class ContactsModule {
}
}
static Future<List<CachedContact>> getContacts(int accountId) async {
final rows = await AppDatabase.loadContacts(accountId);
return rows.map(CachedContact.fromDbRow).toList();
}
static Map<String, dynamic>? _parseContact(
Map<dynamic, dynamic> contact,
int accountId,
+336 -9
View File
@@ -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<CallsTab> createState() => _CallsTabState();
}
class _CallsTabState extends State<CallsTab> {
List<CallLogEntry> _calls = [];
bool _isLoading = true;
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _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<CallLogEntry> 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],
);
},
),
),
],
),
),
);
+185 -9
View File
@@ -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<ContactsTab> createState() => _ContactsTabState();
}
class _ContactsTabState extends State<ContactsTab> {
List<CachedContact> _contacts = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadContacts();
}
Future<void> _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);
},
),
),
],
),
),
);