From 5cb95ac9b1dce3ffbe9e7603b9b005788ccd2020 Mon Sep 17 00:00:00 2001 From: nox Date: Wed, 3 Jun 2026 11:31:14 +0000 Subject: [PATCH] =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B5=D0=BB=D0=BA=D0=B8=D0=B5=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B5=20=D1=87=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/calls/call_screen.dart | 381 ++++++++++++++++++ .../screens/chats/chat_list_screen.dart | 11 +- .../screens/profile/debug_menu_screen.dart | 129 ++++++ 3 files changed, 515 insertions(+), 6 deletions(-) create mode 100644 lib/frontend/screens/calls/call_screen.dart diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart new file mode 100644 index 0000000..4ca0beb --- /dev/null +++ b/lib/frontend/screens/calls/call_screen.dart @@ -0,0 +1,381 @@ +import 'dart:async'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +enum CallScreenState { incoming, outgoing, active } + +class CallScreen extends StatefulWidget { + final String name; + final String? avatarUrl; + final CallScreenState initialState; + + const CallScreen({ + super.key, + required this.name, + this.avatarUrl, + this.initialState = CallScreenState.incoming, + }); + + @override + State createState() => _CallScreenState(); +} + +class _CallScreenState extends State + with SingleTickerProviderStateMixin { + late CallScreenState _state; + Timer? _timer; + int _seconds = 0; + bool _isMuted = false; + bool _isSpeaker = false; + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _state = widget.initialState; + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + if (_state == CallScreenState.outgoing) { + _startOutgoingTimer(); + } + } + + void _startOutgoingTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _seconds++); + if (_seconds >= 3 && _state == CallScreenState.outgoing) { + _timer?.cancel(); + setState(() => _state = CallScreenState.active); + _startActiveTimer(); + } + }); + } + + void _startActiveTimer() { + _seconds = 0; + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() => _seconds++); + }); + } + + String get _timerText { + final m = (_seconds ~/ 60).toString().padLeft(2, '0'); + final s = (_seconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + void _accept() { + setState(() { + _state = CallScreenState.active; + _seconds = 0; + }); + _startActiveTimer(); + } + + void _endCall() { + _timer?.cancel(); + Navigator.pop(context); + } + + @override + void dispose() { + _timer?.cancel(); + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenH = MediaQuery.of(context).size.height; + + return Scaffold( + backgroundColor: const Color(0xFF0E0E14), + body: SafeArea( + child: Column( + children: [ + const Spacer(flex: 3), + _buildAvatar(screenH), + const SizedBox(height: 24), + _buildName(), + const SizedBox(height: 8), + _buildStatus(), + const Spacer(flex: 2), + _buildActions(), + const SizedBox(height: 48), + ], + ), + ), + ); + } + + Widget _buildAvatar(double screenH) { + final size = screenH * 0.18; + final cs = Theme.of(context).colorScheme; + final isRinging = _state == CallScreenState.incoming; + final isOutgoing = _state == CallScreenState.outgoing; + + return AnimatedBuilder( + animation: _pulseAnimation, + builder: (context, child) { + final scale = (isRinging || isOutgoing) + ? _pulseAnimation.value + : 1.0; + return Transform.scale( + scale: scale, + child: child, + ); + }, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer.withValues(alpha: 0.2), + border: Border.all( + color: cs.primary.withValues(alpha: 0.3), + width: 2, + ), + ), + child: ClipOval( + child: widget.avatarUrl != null && widget.avatarUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: widget.avatarUrl!, + fit: BoxFit.cover, + memCacheWidth: 360, + memCacheHeight: 360, + errorWidget: (_, _, _) => _fallbackAvatar(size), + ) + : _fallbackAvatar(size), + ), + ), + ); + } + + Widget _fallbackAvatar(double size) { + final cs = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cs.primaryContainer, + ), + alignment: Alignment.center, + child: Text( + widget.name.isNotEmpty ? widget.name[0].toUpperCase() : '?', + style: TextStyle( + color: cs.onPrimaryContainer, + fontSize: size * 0.4, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildName() { + final cs = Theme.of(context).colorScheme; + return Text( + widget.name, + style: TextStyle( + color: cs.onSurface, + fontSize: 26, + fontWeight: FontWeight.w600, + fontFamily: 'Outfit', + ), + ); + } + + Widget _buildStatus() { + final cs = Theme.of(context).colorScheme; + String text; + switch (_state) { + case CallScreenState.incoming: + text = 'Входящий звонок'; + case CallScreenState.outgoing: + text = 'Вызов...'; + case CallScreenState.active: + text = _timerText; + } + return Text( + text, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ); + } + + Widget _buildActions() { + switch (_state) { + case CallScreenState.incoming: + return _buildIncomingActions(); + case CallScreenState.outgoing: + return _buildOutgoingActions(); + case CallScreenState.active: + return _buildActiveActions(); + } + } + + Widget _buildIncomingActions() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Отклонить', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + const SizedBox(width: 48), + _ActionButton( + icon: Symbols.phone, + label: 'Принять', + color: const Color(0xFF3A691E), + onTap: _accept, + ), + ], + ); + } + + Widget _buildOutgoingActions() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Отмена', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + ], + ); + } + + Widget _buildActiveActions() { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _CircleActionButton( + icon: _isMuted ? Symbols.mic_off : Symbols.mic, + active: _isMuted, + onTap: () => setState(() => _isMuted = !_isMuted), + ), + const SizedBox(width: 32), + _CircleActionButton( + icon: _isMuted ? Symbols.volume_off : Symbols.volume_up, + active: _isSpeaker, + onTap: () => setState(() => _isSpeaker = !_isSpeaker), + ), + const SizedBox(width: 32), + _CircleActionButton( + icon: Symbols.bluetooth_audio, + active: false, + onTap: () {}, + ), + ], + ), + const SizedBox(height: 40), + _ActionButton( + icon: Symbols.phone_disabled, + label: 'Завершить', + color: const Color(0xFFBA1A1A), + onTap: _endCall, + ), + ], + ); + } +} + +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + alignment: Alignment.center, + child: Icon(icon, color: Colors.white, size: 28, fill: 1), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _CircleActionButton extends StatelessWidget { + final IconData icon; + final bool active; + final VoidCallback onTap; + + const _CircleActionButton({ + required this.icon, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active + ? Colors.white.withValues(alpha: 0.2) + : Colors.white.withValues(alpha: 0.1), + ), + alignment: Alignment.center, + child: Icon( + icon, + color: active ? Colors.white : Colors.white70, + size: 24, + fill: 1, + ), + ), + ); + } +} diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 25ba06a..ddd75c1 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -1203,7 +1203,7 @@ class _ChatListScreenState extends State ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 3, 20, 4), + padding: const EdgeInsets.fromLTRB(20, 3, 20, 8), child: Container( height: 44, decoration: BoxDecoration( @@ -1356,7 +1356,7 @@ class _ChatListScreenState extends State parent: const AlwaysScrollableScrollPhysics(), ), slivers: [ - const SliverToBoxAdapter(child: SizedBox(height: 14)), + const SliverToBoxAdapter(child: SizedBox(height: 8)), if (chats.isEmpty && !_isInitialLoading) SliverFillRemaining( child: Center( @@ -1866,9 +1866,7 @@ class _ChatListScreenState extends State onPressed: _toggleFab, backgroundColor: cs.primaryContainer, elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), + shape: const CircleBorder(), child: Transform.rotate( angle: val * (pi / 4), child: Icon( @@ -2074,7 +2072,7 @@ class _ChatListScreenState extends State padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: isSelected ? cs.primaryContainer : cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(50), ), child: Text( title, @@ -2372,6 +2370,7 @@ class _ChatListScreenState extends State icon, color: isSelected ? cs.onPrimary : cs.onSurface, size: 20, + fill: 1, ), AnimatedContainer( duration: animDur, diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index 3f6b5f0..c215493 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -14,6 +14,7 @@ import '../../../core/utils/media_cache.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; +import '../calls/call_screen.dart'; class DebugMenuScreen extends StatefulWidget { const DebugMenuScreen({super.key}); @@ -794,6 +795,91 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Экран звонка', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + 'Превью экранов звонков', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _DebugCallButton( + label: 'Входящий', + icon: Symbols.call_received, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.incoming, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DebugCallButton( + label: 'Исходящий', + icon: Symbols.call_made, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.outgoing, + ), + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DebugCallButton( + label: 'Активный', + icon: Symbols.phone_in_talk, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CallScreen( + name: 'Кирил Г.', + initialState: CallScreenState.active, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -1210,4 +1296,47 @@ class _ErrorChip extends StatelessWidget { ), ); } +} + +class _DebugCallButton extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + + const _DebugCallButton({ + required this.label, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Material( + color: cs.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: cs.onSurfaceVariant, size: 22, fill: 1), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } } \ No newline at end of file