Neda/Front/lib/screens/channel_screen.dart
2026-03-05 23:08:50 +03:30

281 lines
8.1 KiB
Dart

import 'package:flutter/material.dart';
import '../models/channel.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import '../services/ptt_service.dart';
class ChannelScreen extends StatefulWidget {
final Channel channel;
const ChannelScreen({super.key, required this.channel});
@override
State<ChannelScreen> createState() => _ChannelScreenState();
}
class _ChannelScreenState extends State<ChannelScreen>
with SingleTickerProviderStateMixin {
late final PttService _ptt;
late final ApiService _api;
PttState _state = PttState.idle;
String? _speaker;
// Pulse animation for speaking/receiving states
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
@override
void initState() {
super.initState();
final auth = AuthService();
_api = ApiService(auth);
_ptt = PttService();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
);
_pulseAnim = Tween<double>(begin: 1.0, end: 1.12).animate(
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
);
_ptt.stateStream.listen((s) {
if (!mounted) return;
setState(() => _state = s);
if (s == PttState.speaking || s == PttState.receiving) {
_pulseCtrl.repeat(reverse: true);
} else {
_pulseCtrl.stop();
_pulseCtrl.reset();
}
});
_ptt.speakerStream.listen((name) {
if (!mounted) return;
setState(() => _speaker = name);
});
_ptt.errorStream.listen((err) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(err,
style: const TextStyle(fontSize: 11), textAlign: TextAlign.center),
duration: const Duration(seconds: 2),
backgroundColor: const Color(0xFF333333),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
);
});
_connect();
}
Future<void> _connect() async {
final creds = await _api.getLivekitToken(widget.channel.id);
if (!mounted) return;
if (creds == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('دریافت توکن ناموفق بود',
style: TextStyle(fontSize: 11), textAlign: TextAlign.center),
duration: const Duration(seconds: 3),
backgroundColor: const Color(0xFF333333),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
);
return;
}
await _ptt.connect(creds.url, creds.token);
}
@override
void dispose() {
_ptt.dispose();
_pulseCtrl.dispose();
super.dispose();
}
Future<void> _onPttTap() async {
if (_state == PttState.connected) {
await _ptt.startSpeaking();
} else if (_state == PttState.speaking) {
await _ptt.stopSpeaking();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
children: [
// Back button — top-left
Positioned(
top: 2,
left: 2,
child: IconButton(
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 36, minHeight: 36),
icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.white38, size: 16),
),
),
// Main content
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Channel name
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
widget.channel.name,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 20),
// PTT Button
Center(
child: AnimatedBuilder(
animation: _pulseAnim,
builder: (_, child) =>
Transform.scale(scale: _pulseAnim.value, child: child),
child: GestureDetector(
onTap: _onPttTap,
child: _PttButton(state: _state, speaker: _speaker),
),
),
),
const SizedBox(height: 14),
// Status text
_StatusText(state: _state, speaker: _speaker),
],
),
],
),
),
);
}
}
// ────────────────────────────────────────────
class _PttButton extends StatelessWidget {
final PttState state;
final String? speaker;
const _PttButton({required this.state, this.speaker});
@override
Widget build(BuildContext context) {
final (color, icon, label) = switch (state) {
PttState.speaking => (
const Color(0xFFFF1744),
Icons.mic,
'TALKING'
),
PttState.receiving => (
const Color(0xFF2979FF),
Icons.volume_up,
speaker ?? '...',
),
PttState.connected => (
const Color(0xFF00C853),
Icons.settings_input_antenna,
'PUSH',
),
PttState.idle => (
const Color(0xFF424242),
Icons.wifi_off,
'...',
),
};
final bool enabled =
state == PttState.connected || state == PttState.speaking;
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: 130,
height: 130,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: enabled ? 0.45 : 0.15),
blurRadius: state == PttState.speaking ? 24 : 10,
spreadRadius: state == PttState.speaking ? 6 : 2,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: Colors.white, size: 38),
const SizedBox(height: 4),
Text(
label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
letterSpacing: 0.5,
),
),
],
),
);
}
}
// ────────────────────────────────────────────
class _StatusText extends StatelessWidget {
final PttState state;
final String? speaker;
const _StatusText({required this.state, this.speaker});
@override
Widget build(BuildContext context) {
final (text, color) = switch (state) {
PttState.speaking => ('برای قطع کلیک کنید', const Color(0xFFFF1744)),
PttState.receiving => (
'${speaker ?? '...'} در حال صحبت',
const Color(0xFF2979FF)
),
PttState.connected => ('برای صحبت کلیک کنید', Colors.white38),
PttState.idle => ('در حال اتصال...', Colors.white24),
};
return Text(
text,
style: TextStyle(color: color, fontSize: 10),
textAlign: TextAlign.center,
);
}
}