281 lines
8.1 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|