Merge branch 'main' of ssh://94.183.170.121:222/wikm/watch
This commit is contained in:
commit
bedecb82bd
|
|
@ -1,13 +1,20 @@
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url = uri("https://archive.ito.gov.ir/gradle/maven_central") }
|
maven {
|
||||||
|
url = uri("https://maven.aliyun.com/repository/google")
|
||||||
|
}
|
||||||
|
maven {
|
||||||
|
url = uri("https://maven.aliyun.com/repository/central")
|
||||||
|
}
|
||||||
|
maven {
|
||||||
|
url = uri("https://maven.aliyun.com/repository/public")
|
||||||
|
}
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val newBuildDir: Directory =
|
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
|
||||||
rootProject.layout.buildDirectory
|
|
||||||
.dir("../../build")
|
|
||||||
.get()
|
|
||||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
|
|
@ -15,20 +22,7 @@ subprojects {
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
}
|
}
|
||||||
subprojects {
|
subprojects {
|
||||||
val setupNamespace = Action<Project> {
|
project.evaluationDependsOn(":app")
|
||||||
if (hasProperty("android")) {
|
|
||||||
val android = property("android") as? com.android.build.gradle.BaseExtension
|
|
||||||
if (android?.namespace == null) {
|
|
||||||
android?.namespace = "com.github.twyatt.${name.replace("-", ".")}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.executed) {
|
|
||||||
setupNamespace.execute(this)
|
|
||||||
} else {
|
|
||||||
afterEvaluate(setupNamespace)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,2 +1,12 @@
|
||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.daemon=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
|
||||||
|
systemProp.http.proxyHost=127.0.0.1
|
||||||
|
systemProp.http.proxyPort=10809
|
||||||
|
systemProp.https.proxyHost=127.0.0.1
|
||||||
|
systemProp.https.proxyPort=10809
|
||||||
|
systemProp.http.nonProxyHosts=localhost|127.*|[::1]
|
||||||
|
|
@ -1,24 +1,37 @@
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
val flutterSdkPath =
|
val flutterSdkPath = run {
|
||||||
run {
|
val properties = java.util.Properties()
|
||||||
val properties = java.util.Properties()
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
file("local.properties").inputStream().use { properties.load(it) }
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
flutterSdkPath
|
||||||
flutterSdkPath
|
}
|
||||||
}
|
|
||||||
|
|
||||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
maven { url = uri("https://archive.ito.gov.ir/gradle/maven_central") }
|
maven {
|
||||||
|
url = uri("https://maven.aliyun.com/repository/google")
|
||||||
|
}
|
||||||
|
maven {
|
||||||
|
url = uri("https://maven.aliyun.com/repository/central")
|
||||||
|
}
|
||||||
|
maven {
|
||||||
|
url = uri("https://maven.aliyun.com/repository/public")
|
||||||
|
}
|
||||||
|
maven {
|
||||||
|
url = uri("https://maven.aliyun.com/repository/gradle-plugin")
|
||||||
|
}
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "8.7.3" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ class AppConfig {
|
||||||
// ── Debug mode ──────────────────────────────────────────────────────────
|
// ── Debug mode ──────────────────────────────────────────────────────────
|
||||||
// true → از دادههای فیک استفاده میکند (بدون نیاز به سرور)
|
// true → از دادههای فیک استفاده میکند (بدون نیاز به سرور)
|
||||||
// false → به سرور واقعی وصل میشود
|
// false → به سرور واقعی وصل میشود
|
||||||
static const bool debug = true;
|
static const bool debug = false;
|
||||||
|
|
||||||
// ── Server ───────────────────────────────────────────────────────────────
|
// ── Server ───────────────────────────────────────────────────────────────
|
||||||
static const String serverHost = '192.168.1.100';
|
static const String serverHost = '10.225.63.122';
|
||||||
static const int serverPort = 8000;
|
static const int serverPort = 8000;
|
||||||
|
static const int livekitPort = 7880;
|
||||||
static const bool useSecure = false;
|
static const bool useSecure = false;
|
||||||
|
|
||||||
static String get baseUrl =>
|
static String get baseUrl =>
|
||||||
|
|
@ -15,4 +16,8 @@ class AppConfig {
|
||||||
|
|
||||||
static String get wsBaseUrl =>
|
static String get wsBaseUrl =>
|
||||||
'${useSecure ? 'wss' : 'ws'}://$serverHost:$serverPort';
|
'${useSecure ? 'wss' : 'ws'}://$serverHost:$serverPort';
|
||||||
|
|
||||||
|
// آدرس سرور LiveKit (پورت جداگانه از بکند)
|
||||||
|
static String get livekitUrl =>
|
||||||
|
'${useSecure ? 'wss' : 'ws'}://$serverHost:$livekitPort';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,139 +63,6 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showPrivateJoin() async {
|
|
||||||
final keyCtrl = TextEditingController();
|
|
||||||
bool joining = false;
|
|
||||||
String? joinError;
|
|
||||||
|
|
||||||
await showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) {
|
|
||||||
return StatefulBuilder(builder: (ctx, setDialog) {
|
|
||||||
return Dialog(
|
|
||||||
backgroundColor: const Color(0xFF1C1C1E),
|
|
||||||
shape:
|
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.lock_outline,
|
|
||||||
color: Color(0xFFFFAB00), size: 22),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
const Text(
|
|
||||||
'کانال خصوصی',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
SizedBox(
|
|
||||||
height: 34,
|
|
||||||
child: TextField(
|
|
||||||
controller: keyCtrl,
|
|
||||||
style:
|
|
||||||
const TextStyle(color: Colors.white, fontSize: 12),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'کلید ورود',
|
|
||||||
hintStyle: const TextStyle(
|
|
||||||
color: Colors.white38, fontSize: 11),
|
|
||||||
filled: true,
|
|
||||||
fillColor: const Color(0xFF2C2C2E),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10, vertical: 0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (joinError != null) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(joinError!,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFFFF1744), fontSize: 10)),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
),
|
|
||||||
child: const Text('انصراف',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white54, fontSize: 11)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: joining
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final key = keyCtrl.text.trim();
|
|
||||||
if (key.isEmpty) {
|
|
||||||
setDialog(
|
|
||||||
() => joinError = 'کلید را وارد کنید');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDialog(() {
|
|
||||||
joining = true;
|
|
||||||
joinError = null;
|
|
||||||
});
|
|
||||||
final ch =
|
|
||||||
await _api.joinPrivateChannel(key);
|
|
||||||
if (!ctx.mounted) return;
|
|
||||||
if (ch != null) {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
_enterChannel(ch);
|
|
||||||
} else {
|
|
||||||
setDialog(() {
|
|
||||||
joining = false;
|
|
||||||
joinError = 'کلید نادرست';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFFFFAB00),
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14)),
|
|
||||||
),
|
|
||||||
child: joining
|
|
||||||
? const SizedBox(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Colors.black, strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('ورود',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.bold)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
keyCtrl.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -275,13 +142,8 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
itemCount: _channels.length + 1,
|
itemCount: _channels.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
if (i == _channels.length) {
|
|
||||||
// Private join button
|
|
||||||
return _PrivateJoinTile(
|
|
||||||
onTap: _showPrivateJoin);
|
|
||||||
}
|
|
||||||
return _ChannelTile(
|
return _ChannelTile(
|
||||||
channel: _channels[i],
|
channel: _channels[i],
|
||||||
onTap: () => _enterChannel(_channels[i]),
|
onTap: () => _enterChannel(_channels[i]),
|
||||||
|
|
@ -328,15 +190,6 @@ class _ChannelTile extends StatelessWidget {
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (channel.memberCount > 0) ...[
|
|
||||||
Text(
|
|
||||||
'${channel.memberCount}',
|
|
||||||
style: const TextStyle(color: Colors.white38, fontSize: 10),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
const Icon(Icons.person_outline,
|
|
||||||
color: Colors.white38, size: 11),
|
|
||||||
],
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
|
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
|
||||||
],
|
],
|
||||||
|
|
@ -345,46 +198,3 @@ class _ChannelTile extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PrivateJoinTile extends StatelessWidget {
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _PrivateJoinTile({required this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF2C2400),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(
|
|
||||||
color: const Color(0xFFFFAB00).withValues(alpha: 0.4),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.lock_open_outlined,
|
|
||||||
color: Color(0xFFFFAB00), size: 16),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'ورود به کانال خصوصی',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Color(0xFFFFAB00),
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Icon(Icons.chevron_right, color: Color(0xFFFFAB00), size: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
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 '../models/channel.dart';
|
||||||
import '../services/api_service.dart';
|
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import '../services/ptt_service.dart';
|
import '../services/ptt_service.dart';
|
||||||
|
import '../config/app_config.dart';
|
||||||
|
|
||||||
class ChannelScreen extends StatefulWidget {
|
class ChannelScreen extends StatefulWidget {
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
|
|
@ -16,7 +21,6 @@ class ChannelScreen extends StatefulWidget {
|
||||||
class _ChannelScreenState extends State<ChannelScreen>
|
class _ChannelScreenState extends State<ChannelScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late final PttService _ptt;
|
late final PttService _ptt;
|
||||||
late final ApiService _api;
|
|
||||||
|
|
||||||
PttState _state = PttState.idle;
|
PttState _state = PttState.idle;
|
||||||
String? _speaker;
|
String? _speaker;
|
||||||
|
|
@ -25,20 +29,30 @@ class _ChannelScreenState extends State<ChannelScreen>
|
||||||
late final AnimationController _pulseCtrl;
|
late final AnimationController _pulseCtrl;
|
||||||
late final Animation<double> _pulseAnim;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final auth = AuthService();
|
|
||||||
_api = ApiService(auth);
|
|
||||||
_ptt = PttService();
|
_ptt = PttService();
|
||||||
|
|
||||||
_pulseCtrl = AnimationController(
|
_pulseCtrl = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 700),
|
duration: const Duration(milliseconds: 700),
|
||||||
);
|
);
|
||||||
_pulseAnim = Tween<double>(begin: 1.0, end: 1.12).animate(
|
_pulseAnim = Tween<double>(
|
||||||
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
|
begin: 1.0,
|
||||||
);
|
end: 1.12,
|
||||||
|
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||||
|
|
||||||
_ptt.stateStream.listen((s) {
|
_ptt.stateStream.listen((s) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -60,44 +74,105 @@ class _ChannelScreenState extends State<ChannelScreen>
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(err,
|
content: Text(
|
||||||
style: const TextStyle(fontSize: 11), textAlign: TextAlign.center),
|
err,
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
backgroundColor: const Color(0xFF333333),
|
backgroundColor: const Color(0xFF333333),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
|
||||||
shape:
|
shape: RoundedRectangleBorder(
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
WakelockPlus.enable();
|
||||||
_connect();
|
_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 {
|
Future<void> _connect() async {
|
||||||
final creds = await _api.getLivekitToken(widget.channel.id);
|
final token = await AuthService().getToken();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (creds == null) {
|
if (token == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: const Text('دریافت توکن ناموفق بود',
|
content: const Text(
|
||||||
style: TextStyle(fontSize: 11), textAlign: TextAlign.center),
|
'لطفاً دوباره وارد شوید',
|
||||||
|
style: TextStyle(fontSize: 11),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
backgroundColor: const Color(0xFF333333),
|
backgroundColor: const Color(0xFF333333),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
|
||||||
shape:
|
shape: RoundedRectangleBorder(
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _ptt.connect(creds.url, creds.token);
|
await _ptt.connectToGroup(
|
||||||
|
widget.channel.id,
|
||||||
|
token,
|
||||||
|
AppConfig.livekitUrl,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WakelockPlus.disable();
|
||||||
|
_gyroSub?.cancel();
|
||||||
_ptt.dispose();
|
_ptt.dispose();
|
||||||
_pulseCtrl.dispose();
|
_pulseCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -105,8 +180,10 @@ class _ChannelScreenState extends State<ChannelScreen>
|
||||||
|
|
||||||
Future<void> _onPttTap() async {
|
Future<void> _onPttTap() async {
|
||||||
if (_state == PttState.connected) {
|
if (_state == PttState.connected) {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
await _ptt.startSpeaking();
|
await _ptt.startSpeaking();
|
||||||
} else if (_state == PttState.speaking) {
|
} else if (_state == PttState.speaking) {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
await _ptt.stopSpeaking();
|
await _ptt.stopSpeaking();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,60 +193,90 @@ class _ChannelScreenState extends State<ChannelScreen>
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Stack(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Back button — top-left
|
// Channel name
|
||||||
Positioned(
|
Padding(
|
||||||
top: 2,
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
left: 2,
|
child: Text(
|
||||||
child: IconButton(
|
widget.channel.name,
|
||||||
onPressed: () => Navigator.pop(context),
|
textAlign: TextAlign.center,
|
||||||
padding: EdgeInsets.zero,
|
maxLines: 1,
|
||||||
constraints:
|
overflow: TextOverflow.ellipsis,
|
||||||
const BoxConstraints(minWidth: 36, minHeight: 36),
|
style: const TextStyle(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new,
|
color: Colors.white,
|
||||||
color: Colors.white38, size: 16),
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Main content
|
// PTT Button
|
||||||
Column(
|
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,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Channel name
|
// Back button
|
||||||
Padding(
|
IconButton(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text(
|
padding: EdgeInsets.zero,
|
||||||
widget.channel.name,
|
constraints: const BoxConstraints(
|
||||||
textAlign: TextAlign.center,
|
minWidth: 36,
|
||||||
maxLines: 1,
|
minHeight: 36,
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
style: const TextStyle(
|
icon: const Icon(
|
||||||
color: Colors.white,
|
Icons.arrow_back_ios_new,
|
||||||
fontSize: 13,
|
color: Colors.white70,
|
||||||
fontWeight: FontWeight.bold,
|
size: 18,
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// PTT Button
|
// Wrist-flip toggle (فقط اگه ژیروسکوپ دارد)
|
||||||
Center(
|
if (_wristAvailable) ...[
|
||||||
child: AnimatedBuilder(
|
const SizedBox(width: 16),
|
||||||
animation: _pulseAnim,
|
AnimatedContainer(
|
||||||
builder: (_, child) =>
|
duration: const Duration(milliseconds: 200),
|
||||||
Transform.scale(scale: _pulseAnim.value, child: child),
|
width: 36,
|
||||||
child: GestureDetector(
|
height: 36,
|
||||||
onTap: _onPttTap,
|
decoration: BoxDecoration(
|
||||||
child: _PttButton(state: _state, speaker: _speaker),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// Status text
|
|
||||||
_StatusText(state: _state, speaker: _speaker),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -189,26 +296,18 @@ class _PttButton extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final (color, icon, label) = switch (state) {
|
final (color, icon, label) = switch (state) {
|
||||||
PttState.speaking => (
|
PttState.speaking => (const Color(0xFFFF1744), Icons.mic, 'TALKING'),
|
||||||
const Color(0xFFFF1744),
|
|
||||||
Icons.mic,
|
|
||||||
'TALKING'
|
|
||||||
),
|
|
||||||
PttState.receiving => (
|
PttState.receiving => (
|
||||||
const Color(0xFF2979FF),
|
const Color(0xFF2979FF),
|
||||||
Icons.volume_up,
|
Icons.volume_up,
|
||||||
speaker ?? '...',
|
speaker ?? '...',
|
||||||
),
|
),
|
||||||
PttState.connected => (
|
PttState.connected => (
|
||||||
const Color(0xFF00C853),
|
const Color(0xFF00C853),
|
||||||
Icons.settings_input_antenna,
|
Icons.settings_input_antenna,
|
||||||
'PUSH',
|
'PUSH',
|
||||||
),
|
),
|
||||||
PttState.idle => (
|
PttState.idle => (const Color(0xFF424242), Icons.wifi_off, '...'),
|
||||||
const Color(0xFF424242),
|
|
||||||
Icons.wifi_off,
|
|
||||||
'...',
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
final bool enabled =
|
final bool enabled =
|
||||||
|
|
@ -216,24 +315,24 @@ class _PttButton extends StatelessWidget {
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
width: 130,
|
width: 110,
|
||||||
height: 130,
|
height: 110,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: color.withValues(alpha: enabled ? 0.45 : 0.15),
|
color: color.withValues(alpha: enabled ? 0.45 : 0.15),
|
||||||
blurRadius: state == PttState.speaking ? 24 : 10,
|
blurRadius: state == PttState.speaking ? 20 : 8,
|
||||||
spreadRadius: state == PttState.speaking ? 6 : 2,
|
spreadRadius: state == PttState.speaking ? 4 : 2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: Colors.white, size: 38),
|
Icon(icon, color: Colors.white, size: 32),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
|
@ -242,7 +341,7 @@ class _PttButton extends StatelessWidget {
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -251,30 +350,3 @@ class _PttButton extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ class LoginScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
final _tokenCtrl = TextEditingController();
|
final _usernameCtrl = TextEditingController();
|
||||||
|
final _secretCtrl = TextEditingController();
|
||||||
final _authService = AuthService();
|
final _authService = AuthService();
|
||||||
late final ApiService _api;
|
late final ApiService _api;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
|
@ -25,14 +26,16 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tokenCtrl.dispose();
|
_usernameCtrl.dispose();
|
||||||
|
_secretCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _login() async {
|
Future<void> _login() async {
|
||||||
final token = _tokenCtrl.text.trim();
|
final username = _usernameCtrl.text.trim();
|
||||||
if (token.isEmpty) {
|
final secret = _secretCtrl.text.trim();
|
||||||
setState(() => _error = 'توکن را وارد کنید');
|
if (username.isEmpty || secret.isEmpty) {
|
||||||
|
setState(() => _error = 'نام کاربری و کلید را وارد کنید');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -40,7 +43,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final ok = await _api.login(token);
|
final ok = await _api.login(username, secret);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
|
@ -51,7 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_error = 'توکن نادرست است';
|
_error = 'نام کاربری یا کلید نادرست است';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,116 +64,153 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Center(
|
child: SingleChildScrollView(
|
||||||
child: Padding(
|
child: ConstrainedBox(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
constraints: BoxConstraints(
|
||||||
child: Column(
|
minHeight: MediaQuery.of(context).size.height -
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
MediaQuery.of(context).padding.top -
|
||||||
children: [
|
MediaQuery.of(context).padding.bottom,
|
||||||
// Icon
|
),
|
||||||
Container(
|
child: Center(
|
||||||
width: 44,
|
child: Padding(
|
||||||
height: 44,
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
child: Column(
|
||||||
color: const Color(0xFF00C853).withValues(alpha: 0.15),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
shape: BoxShape.circle,
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
children: [
|
||||||
child: const Icon(
|
// Icon
|
||||||
Icons.settings_input_antenna,
|
Container(
|
||||||
color: Color(0xFF00C853),
|
width: 36,
|
||||||
size: 22,
|
height: 36,
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
),
|
color: const Color(0xFF00C853).withValues(alpha: 0.15),
|
||||||
const SizedBox(height: 6),
|
shape: BoxShape.circle,
|
||||||
const Text(
|
),
|
||||||
'WalkieTalkie',
|
child: const Icon(
|
||||||
style: TextStyle(
|
Icons.settings_input_antenna,
|
||||||
color: Colors.white,
|
color: Color(0xFF00C853),
|
||||||
fontSize: 13,
|
size: 18,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
|
|
||||||
// Token input
|
|
||||||
SizedBox(
|
|
||||||
height: 36,
|
|
||||||
child: TextField(
|
|
||||||
controller: _tokenCtrl,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'توکن ورود',
|
|
||||||
hintStyle:
|
|
||||||
const TextStyle(color: Colors.white38, fontSize: 11),
|
|
||||||
filled: true,
|
|
||||||
fillColor: const Color(0xFF1C1C1E),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 0),
|
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _login(),
|
const SizedBox(height: 4),
|
||||||
),
|
const Text(
|
||||||
),
|
'WalkieTalkie',
|
||||||
|
style: TextStyle(
|
||||||
// Error
|
color: Colors.white,
|
||||||
SizedBox(
|
fontSize: 12,
|
||||||
height: 18,
|
fontWeight: FontWeight.bold,
|
||||||
child: _error != null
|
letterSpacing: 1,
|
||||||
? Text(
|
),
|
||||||
_error!,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFFFF1744), fontSize: 10),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Login button
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _loading ? null : _login,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _loading
|
|
||||||
? const Color(0xFF424242)
|
|
||||||
: const Color(0xFF00C853),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: _loading
|
|
||||||
? null
|
|
||||||
: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF00C853).withValues(alpha: 0.4),
|
|
||||||
blurRadius: 12,
|
|
||||||
spreadRadius: 2,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: _loading
|
const SizedBox(height: 10),
|
||||||
? const Center(
|
|
||||||
child: SizedBox(
|
// Username input
|
||||||
width: 22,
|
SizedBox(
|
||||||
height: 22,
|
height: 32,
|
||||||
child: CircularProgressIndicator(
|
child: TextField(
|
||||||
color: Colors.white,
|
controller: _usernameCtrl,
|
||||||
strokeWidth: 2,
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||||
),
|
textAlign: TextAlign.center,
|
||||||
),
|
decoration: InputDecoration(
|
||||||
)
|
hintText: 'نام کاربری',
|
||||||
: const Icon(Icons.login, color: Colors.white, size: 26),
|
hintStyle:
|
||||||
),
|
const TextStyle(color: Colors.white38, fontSize: 10),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF1C1C1E),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 0),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
FocusScope.of(context).nextFocus(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
|
||||||
|
// Secret input
|
||||||
|
SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: TextField(
|
||||||
|
controller: _secretCtrl,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'کلید ورود',
|
||||||
|
hintStyle:
|
||||||
|
const TextStyle(color: Colors.white38, fontSize: 10),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF1C1C1E),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 0),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _login(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Error
|
||||||
|
SizedBox(
|
||||||
|
height: 16,
|
||||||
|
child: _error != null
|
||||||
|
? Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFFF1744), fontSize: 9),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Login button
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _loading ? null : _login,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _loading
|
||||||
|
? const Color(0xFF424242)
|
||||||
|
: const Color(0xFF00C853),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: _loading
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF00C853).withValues(alpha: 0.4),
|
||||||
|
blurRadius: 10,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: _loading
|
||||||
|
? const Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.login, color: Colors.white, size: 22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'ورود',
|
||||||
|
style: TextStyle(color: Colors.white38, fontSize: 9),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
),
|
||||||
const Text(
|
|
||||||
'ورود',
|
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 10),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
// Expected API:
|
// Backend API endpoints:
|
||||||
// POST /api/auth/login body: {"token":"..."} → {"user_id":"..."}
|
// POST /auth/login body: {"username":"...","secret":"..."}
|
||||||
// GET /api/channels header: Authorization: Bearer TOKEN
|
// → {"access_token":"...","token_type":"bearer"}
|
||||||
// → [{"id":"1","name":"Alpha","member_count":3}]
|
// GET /groups/me header: Authorization: Bearer TOKEN
|
||||||
// POST /api/channels/join body: {"key":"..."} → {"id":"...","name":"..."}
|
// → [{"id":"uuid","name":"...","description":"...","is_active":true}]
|
||||||
// GET /api/channels/{id}/livekit-token → {"url":"wss://...","token":"eyJ..."}
|
//
|
||||||
|
// LiveKit token از طریق WebSocket دریافت میشود:
|
||||||
|
// WS /ws/groups/{id}?token={jwt}
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
@ -11,20 +13,13 @@ import '../config/app_config.dart';
|
||||||
import '../models/channel.dart';
|
import '../models/channel.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
|
||||||
// ── LiveKit credentials model ─────────────────────────────────────────────
|
|
||||||
class LivekitCredentials {
|
|
||||||
final String url;
|
|
||||||
final String token;
|
|
||||||
const LivekitCredentials({required this.url, required this.token});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fake data (only used when AppConfig.debug == true) ────────────────────
|
// ── Fake data (only used when AppConfig.debug == true) ────────────────────
|
||||||
const _fakeChannels = [
|
const _fakeChannels = [
|
||||||
{'id': '1', 'name': 'تیم آلفا', 'member_count': 4},
|
{'id': '1', 'name': 'تیم آلفا'},
|
||||||
{'id': '2', 'name': 'پشتیبانی میدانی', 'member_count': 2},
|
{'id': '2', 'name': 'پشتیبانی میدانی'},
|
||||||
{'id': '3', 'name': 'فرماندهی', 'member_count': 7},
|
{'id': '3', 'name': 'فرماندهی'},
|
||||||
{'id': '4', 'name': 'گروه لجستیک', 'member_count': 3},
|
{'id': '4', 'name': 'گروه لجستیک'},
|
||||||
{'id': '5', 'name': 'واحد امنیت', 'member_count': 5},
|
{'id': '5', 'name': 'واحد امنیت'},
|
||||||
];
|
];
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -41,28 +36,47 @@ class ApiService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> login(String token) async {
|
// استخراج user_id از JWT token (بدون نیاز به کتابخانه)
|
||||||
|
String? _extractUserIdFromJwt(String token) {
|
||||||
|
try {
|
||||||
|
final parts = token.split('.');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
// base64url decode payload
|
||||||
|
var payload = parts[1];
|
||||||
|
// اضافه کردن padding اگه لازمه
|
||||||
|
while (payload.length % 4 != 0) {
|
||||||
|
payload += '=';
|
||||||
|
}
|
||||||
|
final decoded = utf8.decode(base64Url.decode(payload));
|
||||||
|
final map = jsonDecode(decoded) as Map<String, dynamic>;
|
||||||
|
return map['sub']?.toString();
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> login(String username, String secret) async {
|
||||||
if (AppConfig.debug) {
|
if (AppConfig.debug) {
|
||||||
await Future.delayed(const Duration(milliseconds: 600));
|
await Future.delayed(const Duration(milliseconds: 600));
|
||||||
await _auth.saveAuth(token: token, userId: 'debug_user');
|
await _auth.saveAuth(token: 'debug_jwt', userId: 'debug_user');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await http
|
final res = await http
|
||||||
.post(
|
.post(
|
||||||
Uri.parse('${AppConfig.baseUrl}/api/auth/login'),
|
Uri.parse('${AppConfig.baseUrl}/auth/login'),
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({'token': token}),
|
body: jsonEncode({'username': username, 'secret': secret}),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
if (res.statusCode == 200) {
|
if (res.statusCode == 200) {
|
||||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
await _auth.saveAuth(
|
final accessToken = data['access_token'] as String?;
|
||||||
token: token,
|
if (accessToken == null) return false;
|
||||||
userId: data['user_id']?.toString(),
|
final userId = _extractUserIdFromJwt(accessToken);
|
||||||
);
|
await _auth.saveAuth(token: accessToken, userId: userId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -82,7 +96,7 @@ class ApiService {
|
||||||
try {
|
try {
|
||||||
final res = await http
|
final res = await http
|
||||||
.get(
|
.get(
|
||||||
Uri.parse('${AppConfig.baseUrl}/api/channels'),
|
Uri.parse('${AppConfig.baseUrl}/users/me/groups'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
|
|
@ -98,66 +112,4 @@ class ApiService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Channel?> joinPrivateChannel(String key) async {
|
|
||||||
if (AppConfig.debug) {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 700));
|
|
||||||
// کلید صحیح در حالت دیباگ: هر چیزی غیر از "wrong"
|
|
||||||
if (key.toLowerCase() == 'wrong') return null;
|
|
||||||
return Channel(
|
|
||||||
id: 'private_${key.hashCode}',
|
|
||||||
name: 'کانال خصوصی ($key)',
|
|
||||||
memberCount: 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final res = await http
|
|
||||||
.post(
|
|
||||||
Uri.parse('${AppConfig.baseUrl}/api/channels/join'),
|
|
||||||
headers: await _headers(),
|
|
||||||
body: jsonEncode({'key': key}),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
return Channel.fromJson(
|
|
||||||
jsonDecode(res.body) as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<LivekitCredentials?> getLivekitToken(String channelId) async {
|
|
||||||
if (AppConfig.debug) {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
|
||||||
return const LivekitCredentials(
|
|
||||||
url: 'wss://fake.livekit.io',
|
|
||||||
token: 'debug_livekit_token',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final res = await http
|
|
||||||
.get(
|
|
||||||
Uri.parse(
|
|
||||||
'${AppConfig.baseUrl}/api/channels/$channelId/livekit-token'),
|
|
||||||
headers: await _headers(),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
final data = jsonDecode(res.body) as Map<String, dynamic>;
|
|
||||||
return LivekitCredentials(
|
|
||||||
url: data['url'] as String,
|
|
||||||
token: data['token'] as String,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,27 @@
|
||||||
// LiveKit PTT protocol:
|
// PTT Protocol (WebSocket + LiveKit):
|
||||||
// connect(url, token) → join LiveKit room (muted by default)
|
//
|
||||||
// startSpeaking() → enable microphone → LiveKit publishes audio track
|
// 1. connectToGroup(groupId, jwtToken, livekitUrl)
|
||||||
// stopSpeaking() → disable microphone → LiveKit unpublishes track
|
// → WS /ws/groups/{groupId}?token={jwtToken}
|
||||||
// Remote audio is played automatically by LiveKit SDK
|
// → دریافت {"type":"livekit_token","token":"..."} از سرور
|
||||||
// ActiveSpeakersChangedEvent fires whenever a participant starts/stops speaking
|
// → اتصال به LiveKit با listener token (can_publish=false)
|
||||||
|
//
|
||||||
|
// 2. startSpeaking()
|
||||||
|
// → ارسال {"type":"request_speak"} روی WS
|
||||||
|
// → دریافت {"type":"speaker_granted","token":"..."} یا {"type":"speaker_busy","speaker":"..."}
|
||||||
|
// → اگه granted: اتصال مجدد LiveKit با speaker token (can_publish=true) + روشن کردن میک
|
||||||
|
//
|
||||||
|
// 3. stopSpeaking()
|
||||||
|
// → خاموش کردن میک
|
||||||
|
// → ارسال {"type":"stop_speak"} روی WS
|
||||||
|
//
|
||||||
|
// Broadcasts از سرور:
|
||||||
|
// {"type":"speaker","user_id":"..."} → کسی شروع به صحبت کرد
|
||||||
|
// {"type":"speaker_released"} → خط آزاد شد
|
||||||
|
// {"type":"presence","users":[...]} → لیست آنلاینها
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:livekit_client/livekit_client.dart';
|
import 'package:livekit_client/livekit_client.dart';
|
||||||
|
|
||||||
|
|
@ -13,9 +30,16 @@ import '../config/app_config.dart';
|
||||||
enum PttState { idle, connected, speaking, receiving }
|
enum PttState { idle, connected, speaking, receiving }
|
||||||
|
|
||||||
class PttService {
|
class PttService {
|
||||||
|
WebSocket? _ws;
|
||||||
Room? _room;
|
Room? _room;
|
||||||
EventsListener<RoomEvent>? _listener;
|
EventsListener<RoomEvent>? _listener;
|
||||||
|
|
||||||
|
String? _livekitUrl;
|
||||||
|
|
||||||
|
// completers برای دریافت async پیامهای WS
|
||||||
|
Completer<String?>? _listenerTokenCompleter;
|
||||||
|
Completer<String?>? _speakGrantCompleter;
|
||||||
|
|
||||||
final _stateCtrl = StreamController<PttState>.broadcast();
|
final _stateCtrl = StreamController<PttState>.broadcast();
|
||||||
final _speakerCtrl = StreamController<String?>.broadcast();
|
final _speakerCtrl = StreamController<String?>.broadcast();
|
||||||
final _errorCtrl = StreamController<String>.broadcast();
|
final _errorCtrl = StreamController<String>.broadcast();
|
||||||
|
|
@ -34,15 +58,123 @@ class PttService {
|
||||||
_stateCtrl.add(s);
|
_stateCtrl.add(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Connect ──────────────────────────────────────────────────────────────
|
// ── اتصال به گروه ────────────────────────────────────────────────────────
|
||||||
Future<bool> connect(String url, String token) async {
|
Future<bool> connectToGroup(
|
||||||
|
String groupId,
|
||||||
|
String jwtToken,
|
||||||
|
String livekitUrl,
|
||||||
|
) async {
|
||||||
if (AppConfig.debug) {
|
if (AppConfig.debug) {
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
_setState(PttState.connected);
|
_setState(PttState.connected);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_livekitUrl = livekitUrl;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final wsUrl =
|
||||||
|
'${AppConfig.wsBaseUrl}/ws/groups/$groupId?token=$jwtToken';
|
||||||
|
_ws = await WebSocket.connect(wsUrl);
|
||||||
|
|
||||||
|
_listenerTokenCompleter = Completer<String?>();
|
||||||
|
|
||||||
|
_ws!.listen(
|
||||||
|
(data) {
|
||||||
|
if (data is String) {
|
||||||
|
final msg = jsonDecode(data) as Map<String, dynamic>;
|
||||||
|
_handleWsMessage(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (_) {
|
||||||
|
_listenerTokenCompleter?.complete(null);
|
||||||
|
if (_state != PttState.idle) {
|
||||||
|
_setState(PttState.idle);
|
||||||
|
_errorCtrl.add('اتصال به سرور قطع شد');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
_listenerTokenCompleter?.complete(null);
|
||||||
|
if (_state != PttState.idle) {
|
||||||
|
_setState(PttState.idle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// منتظر دریافت listener token از سرور
|
||||||
|
final listenerToken = await _listenerTokenCompleter!.future.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
_listenerTokenCompleter = null;
|
||||||
|
|
||||||
|
if (listenerToken == null) {
|
||||||
|
_errorCtrl.add('دریافت توکن ناموفق بود');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _connectLiveKit(livekitUrl, listenerToken);
|
||||||
|
} catch (_) {
|
||||||
|
_errorCtrl.add('اتصال به سرور برقرار نشد');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── پردازش پیامهای WebSocket ─────────────────────────────────────────────
|
||||||
|
void _handleWsMessage(Map<String, dynamic> msg) {
|
||||||
|
final type = msg['type'] as String?;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'livekit_token':
|
||||||
|
// اولین پیام بعد از اتصال: listener token
|
||||||
|
if (_listenerTokenCompleter != null &&
|
||||||
|
!_listenerTokenCompleter!.isCompleted) {
|
||||||
|
_listenerTokenCompleter!.complete(msg['token'] as String?);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'speaker_granted':
|
||||||
|
// درخواست صحبت تایید شد
|
||||||
|
if (_speakGrantCompleter != null && !_speakGrantCompleter!.isCompleted) {
|
||||||
|
_speakGrantCompleter!.complete(msg['token'] as String?);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'speaker_busy':
|
||||||
|
// خط اشغاله
|
||||||
|
if (_speakGrantCompleter != null && !_speakGrantCompleter!.isCompleted) {
|
||||||
|
_speakGrantCompleter!.complete(null);
|
||||||
|
}
|
||||||
|
_errorCtrl.add('خط اشغال است');
|
||||||
|
|
||||||
|
case 'speaker':
|
||||||
|
// broadcast: کسی مشغول صحبت شد
|
||||||
|
if (_state != PttState.speaking) {
|
||||||
|
final userId = msg['user_id'] as String?;
|
||||||
|
_speakerName = userId;
|
||||||
|
_speakerCtrl.add(userId);
|
||||||
|
_setState(PttState.receiving);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'speaker_released':
|
||||||
|
// broadcast: خط آزاد شد
|
||||||
|
if (_state == PttState.receiving) {
|
||||||
|
_speakerName = null;
|
||||||
|
_speakerCtrl.add(null);
|
||||||
|
_setState(PttState.connected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── اتصال به LiveKit ──────────────────────────────────────────────────────
|
||||||
|
Future<bool> _connectLiveKit(
|
||||||
|
String url,
|
||||||
|
String token, {
|
||||||
|
bool setConnectedState = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _listener?.dispose();
|
||||||
|
await _room?.disconnect();
|
||||||
|
|
||||||
_room = Room(
|
_room = Room(
|
||||||
roomOptions: const RoomOptions(
|
roomOptions: const RoomOptions(
|
||||||
adaptiveStream: false,
|
adaptiveStream: false,
|
||||||
|
|
@ -56,29 +188,25 @@ class PttService {
|
||||||
(e) => _onSpeakersChanged(e.speakers),
|
(e) => _onSpeakersChanged(e.speakers),
|
||||||
)
|
)
|
||||||
..on<RoomDisconnectedEvent>((_) {
|
..on<RoomDisconnectedEvent>((_) {
|
||||||
_setState(PttState.idle);
|
if (_state != PttState.speaking) _setState(PttState.idle);
|
||||||
});
|
});
|
||||||
|
|
||||||
await _room!.connect(url, token);
|
await _room!.connect(url, token);
|
||||||
|
|
||||||
// Start muted — mic only enabled when PTT is pressed
|
|
||||||
await _room!.localParticipant?.setMicrophoneEnabled(false);
|
await _room!.localParticipant?.setMicrophoneEnabled(false);
|
||||||
|
|
||||||
_setState(PttState.connected);
|
if (setConnectedState) _setState(PttState.connected);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
_errorCtrl.add('اتصال به سرور برقرار نشد');
|
_errorCtrl.add('اتصال به سرور صوتی برقرار نشد');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Speakers detection ────────────────────────────────────────────────────
|
// ── تشخیص متکلم ──────────────────────────────────────────────────────────
|
||||||
void _onSpeakersChanged(List<Participant> speakers) {
|
void _onSpeakersChanged(List<Participant> speakers) {
|
||||||
// Ignore while I'm the one speaking
|
|
||||||
if (_state == PttState.speaking) return;
|
if (_state == PttState.speaking) return;
|
||||||
|
|
||||||
final remoteSpeakers =
|
final remoteSpeakers = speakers.whereType<RemoteParticipant>().toList();
|
||||||
speakers.whereType<RemoteParticipant>().toList();
|
|
||||||
|
|
||||||
if (remoteSpeakers.isNotEmpty) {
|
if (remoteSpeakers.isNotEmpty) {
|
||||||
final p = remoteSpeakers.first;
|
final p = remoteSpeakers.first;
|
||||||
|
|
@ -92,7 +220,7 @@ class PttService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PTT ───────────────────────────────────────────────────────────────────
|
// ── PTT: شروع صحبت ────────────────────────────────────────────────────────
|
||||||
Future<void> startSpeaking() async {
|
Future<void> startSpeaking() async {
|
||||||
if (_state != PttState.connected) return;
|
if (_state != PttState.connected) return;
|
||||||
|
|
||||||
|
|
@ -101,14 +229,35 @@ class PttService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_speakGrantCompleter = Completer<String?>();
|
||||||
|
_ws?.add(jsonEncode({'type': 'request_speak'}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final speakerToken = await _speakGrantCompleter!.future.timeout(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (speakerToken == null) return; // پیام خطا قبلاً emit شده
|
||||||
|
|
||||||
|
// اتصال مجدد LiveKit با speaker token
|
||||||
|
final ok = await _connectLiveKit(
|
||||||
|
_livekitUrl!,
|
||||||
|
speakerToken,
|
||||||
|
setConnectedState: false,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
await _room?.localParticipant?.setMicrophoneEnabled(true);
|
await _room?.localParticipant?.setMicrophoneEnabled(true);
|
||||||
_setState(PttState.speaking);
|
_setState(PttState.speaking);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_errorCtrl.add('خطا در فعالسازی میکروفون');
|
_errorCtrl.add('خطا در فعالسازی میکروفون');
|
||||||
|
} finally {
|
||||||
|
_speakGrantCompleter = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PTT: پایان صحبت ──────────────────────────────────────────────────────
|
||||||
Future<void> stopSpeaking() async {
|
Future<void> stopSpeaking() async {
|
||||||
if (_state != PttState.speaking) return;
|
if (_state != PttState.speaking) return;
|
||||||
|
|
||||||
|
|
@ -120,10 +269,10 @@ class PttService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _room?.localParticipant?.setMicrophoneEnabled(false);
|
await _room?.localParticipant?.setMicrophoneEnabled(false);
|
||||||
_setState(PttState.connected);
|
} catch (_) {}
|
||||||
} catch (_) {
|
|
||||||
_setState(PttState.connected);
|
_ws?.add(jsonEncode({'type': 'stop_speak'}));
|
||||||
}
|
_setState(PttState.connected);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Debug helper ──────────────────────────────────────────────────────────
|
// ── Debug helper ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -141,7 +290,7 @@ class PttService {
|
||||||
_setState(PttState.connected);
|
_setState(PttState.connected);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Disconnect / Dispose ──────────────────────────────────────────────────
|
// ── قطع اتصال ────────────────────────────────────────────────────────────
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
if (AppConfig.debug) {
|
if (AppConfig.debug) {
|
||||||
_setState(PttState.idle);
|
_setState(PttState.idle);
|
||||||
|
|
@ -156,6 +305,10 @@ class PttService {
|
||||||
await _room?.disconnect();
|
await _room?.disconnect();
|
||||||
_room = null;
|
_room = null;
|
||||||
_listener = null;
|
_listener = null;
|
||||||
|
|
||||||
|
await _ws?.close();
|
||||||
|
_ws = null;
|
||||||
|
|
||||||
_setState(PttState.idle);
|
_setState(PttState.idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,16 @@ import connectivity_plus
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import flutter_webrtc
|
import flutter_webrtc
|
||||||
import livekit_client
|
import livekit_client
|
||||||
|
import package_info_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import wakelock_plus
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||||
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
|
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.3.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.0.0"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -528,6 +544,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.2"
|
version: "0.3.2"
|
||||||
|
sensors_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sensors_plus
|
||||||
|
sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.0"
|
||||||
|
sensors_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sensors_plus_platform_interface
|
||||||
|
sha256: "58815d2f5e46c0c41c40fb39375d3f127306f7742efe3b891c0b1c87e2b5cd5d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -677,6 +709,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.2"
|
version: "15.0.2"
|
||||||
|
wakelock_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: wakelock_plus
|
||||||
|
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
wakelock_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: wakelock_plus_platform_interface
|
||||||
|
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ dependencies:
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
|
sensors_plus: ^7.0.0
|
||||||
|
wakelock_plus: ^1.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user