Files
sevenhill b22654b99f
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Update Qlyra to 1.0.13 with messaging, media and plugin improvements
2026-09-10 17:55:55 +03:00

630 lines
19 KiB
Dart

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<CallsTab> createState() => _CallsTabState();
}
class _CallsTabState extends State<CallsTab>
with ReloadOnReconnect, SpectrumSurface {
List<CallLogEntry> _calls = [];
final Set<String> _removing = {};
bool _isLoading = true;
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
StreamSubscription<LoginStatus>? _loginSub;
@override
void initState() {
super.initState();
if (accountModule.isLoggedIn) {
_loadHistory();
} else {
_loginSub = accountModule.loginStatusStream.listen((status) {
if (status == LoginStatus.success) {
_loginSub?.cancel();
_loginSub = null;
_loadHistory();
}
});
}
}
@override
void dispose() {
_loginSub?.cancel();
super.dispose();
}
@override
void reloadAfterReconnect() {
if (accountModule.isLoggedIn) _loadHistory();
}
Future<void> _loadHistory() async {
final p = await AppDatabase.loadActiveProfile();
if (p == null) {
if (mounted) setState(() => _isLoading = false);
return;
}
final callsModule = CallsModule(api);
List<CallLogEntry> calls;
try {
calls = await callsModule.fetchHistory(p.id, p.id);
} catch (e) {
if (mounted) setState(() => _isLoading = false);
return;
}
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,
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(int timestamp) {
if (timestamp == 0) return '';
final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
return '${dt.day} ${kRuMonthsShort[dt.month - 1]}';
}
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: 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(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;
showChatMenu(
context: context,
anchorRect: anchorRect,
items: [
ChatMenuItem(
icon: Symbols.delete,
label: 'Удалить',
destructive: true,
onTap: () => _deleteCall(call),
),
if (!call.isGroup)
ChatMenuItem(
icon: Symbols.call,
label: 'Перезвонить',
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<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 _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<void> _createGroupCall() async {
final controller = CallController.instance;
if (controller.isBusy) {
showCustomNotification(context, 'Звонок уже идёт');
return;
}
final l10n = AppLocalizations.of(context)!;
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, 'Не удалось начать звонок: $e');
}
}
Future<void> _joinGroupCall() async {
if (CallController.instance.isBusy) {
showCustomNotification(context, 'Звонок уже идёт');
return;
}
final url = await showTextInputDialog(
context,
title: 'Присоединиться к звонку',
description: 'Вставьте ссылку-приглашение',
hint: 'https://max.ru/joincall/...',
confirmLabel: 'Присоединиться',
keyboardType: TextInputType.url,
);
if (url == null || url.trim().isEmpty || !mounted) return;
final handled = await tryHandleCallLink(context, url.trim());
if (!handled && mounted) {
showCustomNotification(context, 'Это не ссылка на звонок');
}
}
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 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(
'Звонки',
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: 'Создать звонок',
onTap: _createGroupCall,
),
),
Expanded(
child: _buildLinkAction(
cs,
icon: Symbols.group_add,
label: 'Присоединиться',
onTap: _joinGroupCall,
alignEnd: true,
),
),
],
),
),
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: SmallSpinner(size: 36))
: 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) {
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<double> _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),
);
}
}