fix
This commit is contained in:
@@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user