Neda/Front/lib/screens/channel_screen.dart
2026-03-08 09:53:45 +03:30

379 lines
11 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../models/channel.dart';
import '../services/auth_service.dart';
import '../services/ptt_service.dart';
import '../config/app_config.dart';
import 'group_members_screen.dart';
class ChannelScreen extends StatefulWidget {
final Channel channel;
final String? currentUserId;
const ChannelScreen({super.key, required this.channel, this.currentUserId});
@override
State<ChannelScreen> createState() => _ChannelScreenState();
}
class _ChannelScreenState extends State<ChannelScreen>
with SingleTickerProviderStateMixin {
late final PttService _ptt;
PttState _state = PttState.idle;
String? _speaker;
// Pulse animation for speaking/receiving states
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
// ── Wrist-flip detection (gyroscope) ────────────────────────────────
static const double _gyroThreshold = 9.0; // rad/s
static const Duration _flipWindow = Duration(milliseconds: 900);
static const Duration _flipCooldown = Duration(milliseconds: 380);
bool _wristEnabled = false;
bool _wristAvailable = false;
DateTime? _firstFlipTime;
DateTime? _lastFlipTime;
StreamSubscription<GyroscopeEvent>? _gyroSub;
@override
void initState() {
super.initState();
_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),
),
),
);
});
WakelockPlus.enable();
_connect();
_checkGyroscope();
}
Future<void> _checkGyroscope() async {
try {
final sub = gyroscopeEventStream().listen((_) {});
await sub.cancel();
if (mounted) setState(() => _wristAvailable = true);
} catch (_) {}
}
void _toggleWrist() {
setState(() => _wristEnabled = !_wristEnabled);
if (_wristEnabled) {
_gyroSub = gyroscopeEventStream().listen(_onGyroscope);
} else {
_gyroSub?.cancel();
_gyroSub = null;
_firstFlipTime = null;
_lastFlipTime = null;
}
}
void _onGyroscope(GyroscopeEvent event) {
final magnitude = sqrt(
event.x * event.x + event.y * event.y + event.z * event.z,
);
if (magnitude < _gyroThreshold) return;
final now = DateTime.now();
if (_lastFlipTime != null &&
now.difference(_lastFlipTime!) < _flipCooldown) {
return;
}
_lastFlipTime = now;
if (_firstFlipTime == null ||
now.difference(_firstFlipTime!) > _flipWindow) {
_firstFlipTime = now;
} else {
_firstFlipTime = null;
_onPttTap();
}
}
Future<void> _connect() async {
final token = await AuthService().getToken();
if (!mounted) return;
if (token == 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.connectToGroup(
widget.channel.id,
token,
AppConfig.livekitUrl,
);
}
@override
void dispose() {
WakelockPlus.disable();
_gyroSub?.cancel();
_ptt.dispose();
_pulseCtrl.dispose();
super.dispose();
}
Future<void> _onPttTap() async {
if (_state == PttState.connected) {
HapticFeedback.heavyImpact();
await _ptt.startSpeaking();
} else if (_state == PttState.speaking) {
HapticFeedback.mediumImpact();
await _ptt.stopSpeaking();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Channel name
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
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: 10),
// 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: 10),
// Bottom bar: back + members + wrist-flip toggle
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Back button
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.white70,
size: 18,
),
),
// Members button
const SizedBox(width: 16),
IconButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => GroupMembersScreen(
channel: widget.channel,
currentUserId: widget.currentUserId,
),
),
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
icon: const Icon(
Icons.group_outlined,
color: Colors.white54,
size: 18,
),
),
// Wrist-flip toggle (فقط اگه ژیروسکوپ دارد)
if (_wristAvailable) ...[
const SizedBox(width: 16),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
color: _wristEnabled
? const Color(0xFF00C853).withValues(alpha: 0.2)
: Colors.transparent,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: _toggleWrist,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
icon: Icon(
Icons.rotate_right,
color: _wristEnabled
? const Color(0xFF00C853)
: Colors.white38,
size: 18,
),
),
),
],
],
),
],
),
),
);
}
}
// ────────────────────────────────────────────
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: 110,
height: 110,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: enabled ? 0.45 : 0.15),
blurRadius: state == PttState.speaking ? 20 : 8,
spreadRadius: state == PttState.speaking ? 4 : 2,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: Colors.white, size: 32),
const SizedBox(height: 3),
Text(
label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 10,
letterSpacing: 0.5,
),
),
],
),
);
}
}