import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; 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/qlyra_avatar.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/chat_menu_overlay.dart'; import '../../widgets/small_spinner.dart'; import '../../widgets/prompt_dialog.dart'; import '../../widgets/call_link_handler.dart'; import '../../widgets/spectrum_tint.dart'; import '../../../l10n/app_localizations.dart'; import 'call_link_sheet.dart'; import 'call_screen.dart'; import '../../../core/config/app_fonts.dart'; class CallsTab extends StatefulWidget { const CallsTab({super.key}); @override State createState() => _CallsTabState(); } class _CallsTabState extends State with ReloadOnReconnect, SpectrumSurface { List _calls = []; final Set _removing = {}; bool _isLoading = true; int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные' StreamSubscription? _loginSub; StreamSubscription? _endedSub; int _historyGeneration = 0; @override void initState() { super.initState(); _endedSub = CallController.instance.callEnded.listen((_) { if (accountModule.isLoggedIn) _loadHistory(); }); if (accountModule.isLoggedIn) { _loadHistory(); } else { _loginSub = accountModule.loginStatusStream.listen((status) { if (status == LoginStatus.success) { _loginSub?.cancel(); _loginSub = null; _loadHistory(); } }); } } @override void dispose() { _loginSub?.cancel(); _endedSub?.cancel(); super.dispose(); } @override void reloadAfterReconnect() { if (accountModule.isLoggedIn) _loadHistory(); } Future _loadHistory() async { final generation = ++_historyGeneration; final p = await AppDatabase.loadActiveProfile(); if (!mounted || generation != _historyGeneration) return; if (p == null) { if (mounted) setState(() => _isLoading = false); return; } final callsModule = CallsModule(api); List calls; try { calls = await callsModule.fetchHistory(p.id, p.id); } catch (e) { if (mounted && generation == _historyGeneration) { setState(() => _isLoading = false); } return; } if (!mounted || generation != _historyGeneration) return; 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, isGroup: last.isGroup, ), ); } 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(AppLocalizations l10n, int timestamp) { if (timestamp == 0) return ''; return formatDayMonth(l10n, DateTime.fromMillisecondsSinceEpoch(timestamp)); } Widget _buildCallItem( BuildContext context, ColorScheme cs, CallLogEntry call, ) { final bool isMissed = call.status == CallStatus.missed; final l10n = AppLocalizations.of(context)!; String statusText; IconData statusIcon; switch (call.status) { case CallStatus.missed: statusText = l10n.callsTabStatusMissed; statusIcon = Symbols.phone_missed; break; case CallStatus.canceled: statusText = l10n.callsTabStatusCanceled; statusIcon = Symbols.phone_disabled; break; case CallStatus.outgoing: statusText = l10n.callsTabStatusOutgoing; statusIcon = Symbols.call_made; break; case CallStatus.incoming: statusText = l10n.callsTabStatusIncoming; 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: call.isGroup ? null : () => _callBack(call), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( children: [ Container( width: 48, 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: call.isGroup ? Icon( Symbols.groups, color: cs.onPrimaryContainer, size: 26, ) : QlyraAvatar( name: call.name, imageUrl: call.avatarUrl, size: 48, ), ), 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(l10n, call.time), style: TextStyle( color: cs.onSurfaceVariant.withValues(alpha: 0.7), fontSize: 12, ), ), const SizedBox(width: 4), Builder( builder: (btnContext) => IconButton( icon: Icon( Symbols.more_vert, color: cs.onSurfaceVariant, size: 20, weight: 400, ), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, constraints: const BoxConstraints( minWidth: 36, minHeight: 36, ), onPressed: () => _showCallMenu(btnContext, call), ), ), ], ), ), ), ); } void _showCallMenu(BuildContext anchorContext, CallLogEntry call) { final box = anchorContext.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; final anchorRect = box.localToGlobal(Offset.zero) & box.size; final l10n = AppLocalizations.of(context)!; showChatMenu( context: context, anchorRect: anchorRect, items: [ ChatMenuItem( icon: Symbols.delete, label: l10n.msgActionsDelete, destructive: true, onTap: () => _deleteCall(call), ), if (!call.isGroup) ChatMenuItem( icon: Symbols.call, label: l10n.callsTabCallBack, onTap: () => _callBack(call), ), ], ); } void _deleteCall(CallLogEntry call) { if (_removing.contains(call.id)) return; setState(() => _removing.add(call.id)); final historyId = int.tryParse(call.id); if (historyId != null) { unawaited(CallsModule(api).deleteHistory([historyId])); } } void _onRemovalComplete(String id) { if (!mounted) return; setState(() { _calls.removeWhere((c) => c.id == id); _removing.remove(id); }); } Future _callBack(CallLogEntry call) async { if (call.peerId <= 0) { showCustomNotification( context, AppLocalizations.of(context)!.callsTabPeerUnknown, ); 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, AppLocalizations.of(context)!.chatInfoCallFailed, ); } } Widget _buildLinkAction( ColorScheme cs, { required IconData icon, required String label, required VoidCallback onTap, bool alignEnd = false, }) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), child: Row( mainAxisAlignment: alignEnd ? MainAxisAlignment.end : MainAxisAlignment.start, children: [ Icon(icon, color: cs.primary, size: 24), const SizedBox(width: 12), Flexible( child: Text( label, style: TextStyle( color: cs.primary, fontSize: 16, fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ), ); } Future _createGroupCall() async { final controller = CallController.instance; final l10n = AppLocalizations.of(context)!; if (controller.isBusy) { showCustomNotification(context, l10n.callsTabAlreadyInCall); return; } CreatedCall created; try { created = await controller.createConference(); } catch (e) { if (mounted) { showCustomNotification(context, '${l10n.callLinkCreateFailed}: $e'); } return; } if (!mounted) return; final start = await showCreatedCallSheet(context, call: created); if (!start || !mounted) return; final navigator = Navigator.of(context); final name = created.callName ?? l10n.callLinkGroupCall; try { final session = await controller.joinByLink(created.joinToken); if (!mounted) return; await navigator.push( MaterialPageRoute( builder: (_) => CallScreen(name: name, session: session, isGroup: true), ), ); } catch (e) { if (!mounted) return; showCustomNotification(context, l10n.callsTabStartFailed('$e')); } } Future _joinGroupCall() async { final l10n = AppLocalizations.of(context)!; if (CallController.instance.isBusy) { showCustomNotification(context, l10n.callsTabAlreadyInCall); return; } final url = await showTextInputDialog( context, title: l10n.callsTabJoinTitle, description: l10n.callsTabJoinDescription, hint: 'https://ikar.kusoft.xyz/joincall/...', confirmLabel: l10n.chatCallJoin, keyboardType: TextInputType.url, ); if (url == null || url.trim().isEmpty || !mounted) return; final handled = await tryHandleCallLink(context, url.trim()); if (!handled && mounted) { showCustomNotification(context, l10n.callsTabNotACallLink); } } 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; final l10n = AppLocalizations.of(context)!; final filteredCalls = _selectedTabIndex == 1 ? _calls.where((c) => c.status == CallStatus.missed).toList() : _calls; return Scaffold( backgroundColor: spectrumSurfaceColor(cs), body: SafeArea( bottom: false, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.chatListNavCalls, style: TextStyle( color: cs.onSurface, fontSize: 24, fontWeight: FontWeight.w700, fontFamily: displayFontOf(context), ), ), const ConnectionStatusLine(), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ Expanded( child: _buildLinkAction( cs, icon: Symbols.link, label: l10n.callsTabCreateCall, onTap: _createGroupCall, ), ), Expanded( child: _buildLinkAction( cs, icon: Symbols.group_add, label: l10n.chatCallJoin, onTap: _joinGroupCall, alignEnd: true, ), ), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), child: Row( children: [ _buildTabItem(l10n.reactionsSummaryAll, 0, cs), const SizedBox(width: 8), _buildTabItem(l10n.callsTabMissed, 1, cs), ], ), ), Expanded( child: _isLoading ? const Center(child: SmallSpinner(size: 36)) : filteredCalls.isEmpty ? Center( child: Text( l10n.callsTabEmpty, style: TextStyle( color: cs.onSurfaceVariant, fontSize: 16, ), ), ) : ListView.builder( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.only(bottom: 120), itemCount: filteredCalls.length, itemBuilder: (context, index) { final call = filteredCalls[index]; return _RemovableCallEntry( key: ValueKey(call.id), removing: _removing.contains(call.id), onDismissed: () => _onRemovalComplete(call.id), child: _buildCallItem(context, cs, call), ); }, ), ), ], ), ), ); } } class _RemovableCallEntry extends StatefulWidget { final bool removing; final VoidCallback onDismissed; final Widget child; const _RemovableCallEntry({ required Key key, required this.removing, required this.onDismissed, 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 _animation = CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, ); @override void initState() { super.initState(); _controller.addStatusListener(_onStatus); } void _onStatus(AnimationStatus status) { if (status == AnimationStatus.dismissed) widget.onDismissed(); } @override void didUpdateWidget(covariant _RemovableCallEntry oldWidget) { super.didUpdateWidget(oldWidget); if (widget.removing && !oldWidget.removing) _controller.reverse(); } @override void dispose() { _controller.removeStatusListener(_onStatus); _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizeTransition( sizeFactor: _animation, alignment: Alignment.topCenter, child: FadeTransition(opacity: _animation, child: widget.child), ); } }