fix
This commit is contained in:
+110
-18
@@ -53,6 +53,7 @@ class CallLogEntry {
|
|||||||
final CallStatus status;
|
final CallStatus status;
|
||||||
final int time;
|
final int time;
|
||||||
final int count;
|
final int count;
|
||||||
|
final bool isGroup;
|
||||||
|
|
||||||
const CallLogEntry({
|
const CallLogEntry({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -63,6 +64,7 @@ class CallLogEntry {
|
|||||||
required this.status,
|
required this.status,
|
||||||
required this.time,
|
required this.time,
|
||||||
this.count = 1,
|
this.count = 1,
|
||||||
|
this.isGroup = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,22 +199,58 @@ class CallsModule {
|
|||||||
if (!response.isOk || response.payload is! Map) return [];
|
if (!response.isOk || response.payload is! Map) return [];
|
||||||
|
|
||||||
final payload = response.payload as Map<dynamic, dynamic>;
|
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)
|
/// Парсинг истории звонков (opcode 79: videoChatHistory)
|
||||||
static Future<List<CallLogEntry>> parseHistoryPayload(
|
static Future<List<CallLogEntry>> parseHistoryPayload(
|
||||||
Map<dynamic, dynamic> payload,
|
Map<dynamic, dynamic> payload,
|
||||||
int accountId,
|
int accountId,
|
||||||
int currentUserId,
|
int currentUserId, {
|
||||||
) async {
|
Future<Map<int, Map<String, dynamic>>> Function(List<int> ids)? resolver,
|
||||||
|
}) async {
|
||||||
final history = payload['history'];
|
final history = payload['history'];
|
||||||
if (history is! List || history.isEmpty) return [];
|
if (history is! List || history.isEmpty) return [];
|
||||||
|
|
||||||
final recentContacts = await ContactsModule.getContacts(accountId);
|
final recentContacts = await ContactsModule.getContacts(accountId);
|
||||||
final contactsMap = {for (final c in recentContacts) c.id: c};
|
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>()) {
|
for (final item in history.whereType<Map>()) {
|
||||||
final msg = item['message'];
|
final msg = item['message'];
|
||||||
@@ -241,26 +279,63 @@ class CallsModule {
|
|||||||
peerId = senderId;
|
peerId = senderId;
|
||||||
}
|
}
|
||||||
|
|
||||||
final contact = contactsMap[peerId];
|
parsed.add((
|
||||||
final status = _parseCallStatus(callAttach, isOutgoing);
|
peerId: peerId,
|
||||||
final time = (msg['time'] as int?) ?? 0;
|
status: _parseCallStatus(callAttach, isOutgoing),
|
||||||
final msgId =
|
time: (msg['time'] as int?) ?? 0,
|
||||||
msg['id']?.toString() ??
|
id:
|
||||||
DateTime.now().millisecondsSinceEpoch.toString();
|
msg['id']?.toString() ??
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
final name = (contact != null && contact.firstName.isNotEmpty)
|
bool localResolved(int id) {
|
||||||
? '${contact.firstName} ${contact.lastName ?? ''}'.trim()
|
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(
|
extractedCalls.add(
|
||||||
CallLogEntry(
|
CallLogEntry(
|
||||||
id: msgId,
|
id: e.id,
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
peerId: peerId,
|
peerId: e.peerId,
|
||||||
name: name,
|
name: name,
|
||||||
avatarUrl: contact?.baseUrl,
|
avatarUrl: avatarUrl,
|
||||||
status: status,
|
status: e.status,
|
||||||
time: time,
|
time: e.time,
|
||||||
|
isGroup: isGroup,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,6 +343,23 @@ class CallsModule {
|
|||||||
return extractedCalls;
|
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(
|
static CallStatus _parseCallStatus(
|
||||||
Map<dynamic, dynamic> callAttach,
|
Map<dynamic, dynamic> callAttach,
|
||||||
bool isOutgoing,
|
bool isOutgoing,
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ abstract class Opcode {
|
|||||||
static const int chatMembersUpdate = 77; // Обновление участников / добавление
|
static const int chatMembersUpdate = 77; // Обновление участников / добавление
|
||||||
static const int videoChatStartActive = 78; // Инициация активного звонка
|
static const int videoChatStartActive = 78; // Инициация активного звонка
|
||||||
static const int videoChatHistory = 79; // История звонков
|
static const int videoChatHistory = 79; // История звонков
|
||||||
|
static const int videoChatDeleteHistory = 164; // Удаление записей истории звонков
|
||||||
static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат
|
static const int videoChatCreateJoinLink = 84; // Ссылка для входа в видеочат
|
||||||
static const int videoChatJoinByLink = 166; // Вход в звонок по ссылке
|
static const int videoChatJoinByLink = 166; // Вход в звонок по ссылке
|
||||||
static const int videoChatMembers = 195; // Участники видеочата
|
static const int videoChatMembers = 195; // Участники видеочата
|
||||||
@@ -288,6 +289,7 @@ abstract class Opcode {
|
|||||||
chatMembersUpdate: 'CHAT_MEMBERS_UPDATE',
|
chatMembersUpdate: 'CHAT_MEMBERS_UPDATE',
|
||||||
videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE',
|
videoChatStartActive: 'VIDEO_CHAT_START_ACTIVE',
|
||||||
videoChatHistory: 'VIDEO_CHAT_HISTORY',
|
videoChatHistory: 'VIDEO_CHAT_HISTORY',
|
||||||
|
videoChatDeleteHistory: 'VIDEO_CHAT_DELETE_HISTORY',
|
||||||
videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK',
|
videoChatCreateJoinLink: 'VIDEO_CHAT_CREATE_JOIN_LINK',
|
||||||
videoChatJoinByLink: 'VIDEO_CHAT_JOIN_BY_LINK',
|
videoChatJoinByLink: 'VIDEO_CHAT_JOIN_BY_LINK',
|
||||||
videoChatMembers: 'VIDEO_CHAT_MEMBERS',
|
videoChatMembers: 'VIDEO_CHAT_MEMBERS',
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import '../../../main.dart' show api, accountModule;
|
|||||||
import '../../../backend/modules/account.dart';
|
import '../../../backend/modules/account.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/utils/format.dart';
|
import '../../../core/utils/format.dart';
|
||||||
|
import '../../../core/calls/call_controller.dart';
|
||||||
import '../../../backend/modules/calls.dart';
|
import '../../../backend/modules/calls.dart';
|
||||||
import '../../widgets/komet_avatar.dart';
|
import '../../widgets/komet_avatar.dart';
|
||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
|
import '../../widgets/custom_notification.dart';
|
||||||
import '../../widgets/chat_menu_overlay.dart';
|
import '../../widgets/chat_menu_overlay.dart';
|
||||||
|
import 'call_screen.dart';
|
||||||
|
|
||||||
class CallsTab extends StatefulWidget {
|
class CallsTab extends StatefulWidget {
|
||||||
const CallsTab({super.key});
|
const CallsTab({super.key});
|
||||||
@@ -20,6 +23,7 @@ class CallsTab extends StatefulWidget {
|
|||||||
|
|
||||||
class _CallsTabState extends State<CallsTab> {
|
class _CallsTabState extends State<CallsTab> {
|
||||||
List<CallLogEntry> _calls = [];
|
List<CallLogEntry> _calls = [];
|
||||||
|
final Set<String> _removing = {};
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
|
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
|
||||||
StreamSubscription<LoginStatus>? _loginSub;
|
StreamSubscription<LoginStatus>? _loginSub;
|
||||||
@@ -79,6 +83,7 @@ class _CallsTabState extends State<CallsTab> {
|
|||||||
status: last.status,
|
status: last.status,
|
||||||
time: last.time,
|
time: last.time,
|
||||||
count: last.count + 1,
|
count: last.count + 1,
|
||||||
|
isGroup: last.isGroup,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -154,16 +159,23 @@ class _CallsTabState extends State<CallsTab> {
|
|||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
|
color: call.isGroup ? cs.primaryContainer : null,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: cs.primary.withValues(alpha: 0.1),
|
color: cs.primary.withValues(alpha: 0.1),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: KometAvatar(
|
child: call.isGroup
|
||||||
name: call.name,
|
? Icon(
|
||||||
imageUrl: call.avatarUrl,
|
Symbols.groups,
|
||||||
size: 48,
|
color: cs.onPrimaryContainer,
|
||||||
),
|
size: 26,
|
||||||
|
)
|
||||||
|
: KometAvatar(
|
||||||
|
name: call.name,
|
||||||
|
imageUrl: call.avatarUrl,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -244,17 +256,69 @@ class _CallsTabState extends State<CallsTab> {
|
|||||||
icon: Symbols.delete,
|
icon: Symbols.delete,
|
||||||
label: 'Удалить',
|
label: 'Удалить',
|
||||||
destructive: true,
|
destructive: true,
|
||||||
onTap: () {},
|
onTap: () => _deleteCall(call),
|
||||||
),
|
|
||||||
ChatMenuItem(
|
|
||||||
icon: Symbols.call,
|
|
||||||
label: 'Перезвонить',
|
|
||||||
onTap: () {},
|
|
||||||
),
|
),
|
||||||
|
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) {
|
Widget _buildTabItem(String label, int index, ColorScheme cs) {
|
||||||
final isSelected = _selectedTabIndex == index;
|
final isSelected = _selectedTabIndex == index;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -369,10 +433,11 @@ class _CallsTabState extends State<CallsTab> {
|
|||||||
padding: const EdgeInsets.only(bottom: 120),
|
padding: const EdgeInsets.only(bottom: 120),
|
||||||
itemCount: filteredCalls.length,
|
itemCount: filteredCalls.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return _buildCallItem(
|
final call = filteredCalls[index];
|
||||||
context,
|
return _RemovableCallEntry(
|
||||||
cs,
|
key: ValueKey(call.id),
|
||||||
filteredCalls[index],
|
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:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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)),
|
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 }
|
enum _HitKind { dialog, chat, channel, bot, official, contact, user, unknown }
|
||||||
|
|
||||||
class _SearchHit {
|
class _SearchHit {
|
||||||
|
|||||||
@@ -154,8 +154,15 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer>
|
|||||||
final totalItems = _accounts.length;
|
final totalItems = _accounts.length;
|
||||||
final height = _vPad * 2 + totalItems * _itemHeight + _addItemHeight;
|
final height = _vPad * 2 + totalItems * _itemHeight + _addItemHeight;
|
||||||
|
|
||||||
double menuX = widget.tapPoint.dx - _menuWidth / 2;
|
final maxWidth = screen.width - 2 * _hMargin;
|
||||||
menuX = menuX.clamp(_hMargin, screen.width - _menuWidth - _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 bottomInset = MediaQuery.viewPaddingOf(context).bottom;
|
||||||
final maxBottom = screen.height - bottomInset - 88;
|
final maxBottom = screen.height - bottomInset - 88;
|
||||||
@@ -166,19 +173,19 @@ class _AccountSwitcherLayerState extends State<_AccountSwitcherLayer>
|
|||||||
double menuY = menuBottom - height;
|
double menuY = menuBottom - height;
|
||||||
if (menuY < 24) menuY = 24;
|
if (menuY < 24) menuY = 24;
|
||||||
|
|
||||||
_menuRect = Rect.fromLTWH(menuX, menuY, _menuWidth, height);
|
_menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, height);
|
||||||
_itemHitRects = [
|
_itemHitRects = [
|
||||||
for (int i = 0; i < totalItems; i++)
|
for (int i = 0; i < totalItems; i++)
|
||||||
Rect.fromLTWH(
|
Rect.fromLTWH(
|
||||||
menuX,
|
menuX,
|
||||||
menuY + _vPad + i * _itemHeight,
|
menuY + _vPad + i * _itemHeight,
|
||||||
_menuWidth,
|
menuWidth,
|
||||||
_itemHeight,
|
_itemHeight,
|
||||||
),
|
),
|
||||||
Rect.fromLTWH(
|
Rect.fromLTWH(
|
||||||
menuX,
|
menuX,
|
||||||
menuY + _vPad + totalItems * _itemHeight,
|
menuY + _vPad + totalItems * _itemHeight,
|
||||||
_menuWidth,
|
menuWidth,
|
||||||
_addItemHeight,
|
_addItemHeight,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user