353 lines
10 KiB
Dart
353 lines
10 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';
|
|
|
|
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;
|
|
|
|
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 + 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,
|
|
),
|
|
),
|
|
|
|
// 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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|