Merge branch 'main' of ssh://94.183.170.121:222/wikm/watch

This commit is contained in:
roai_linux 2026-03-06 20:03:14 +03:30
commit bedecb82bd
13 changed files with 666 additions and 563 deletions

View File

@ -1,13 +1,20 @@
allprojects {
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 =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
@ -15,20 +22,7 @@ subprojects {
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
val setupNamespace = Action<Project> {
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)
}
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,12 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
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]

View File

@ -1,24 +1,37 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
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 {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

View File

@ -3,11 +3,12 @@ class AppConfig {
// Debug mode
// true از دادههای فیک استفاده میکند (بدون نیاز به سرور)
// false به سرور واقعی وصل میشود
static const bool debug = true;
static const bool debug = false;
// 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 livekitPort = 7880;
static const bool useSecure = false;
static String get baseUrl =>
@ -15,4 +16,8 @@ class AppConfig {
static String get wsBaseUrl =>
'${useSecure ? 'wss' : 'ws'}://$serverHost:$serverPort';
// آدرس سرور LiveKit (پورت جداگانه از بکند)
static String get livekitUrl =>
'${useSecure ? 'wss' : 'ws'}://$serverHost:$livekitPort';
}

View File

@ -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
Widget build(BuildContext context) {
return Scaffold(
@ -275,13 +142,8 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 4),
itemCount: _channels.length + 1,
itemCount: _channels.length,
itemBuilder: (ctx, i) {
if (i == _channels.length) {
// Private join button
return _PrivateJoinTile(
onTap: _showPrivateJoin);
}
return _ChannelTile(
channel: _channels[i],
onTap: () => _enterChannel(_channels[i]),
@ -328,15 +190,6 @@ class _ChannelTile extends StatelessWidget {
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 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),
],
),
),
);
}
}

View File

@ -1,8 +1,13 @@
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/api_service.dart';
import '../services/auth_service.dart';
import '../services/ptt_service.dart';
import '../config/app_config.dart';
class ChannelScreen extends StatefulWidget {
final Channel channel;
@ -16,7 +21,6 @@ class ChannelScreen extends StatefulWidget {
class _ChannelScreenState extends State<ChannelScreen>
with SingleTickerProviderStateMixin {
late final PttService _ptt;
late final ApiService _api;
PttState _state = PttState.idle;
String? _speaker;
@ -25,20 +29,30 @@ class _ChannelScreenState extends State<ChannelScreen>
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();
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),
);
_pulseAnim = Tween<double>(
begin: 1.0,
end: 1.12,
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
_ptt.stateStream.listen((s) {
if (!mounted) return;
@ -60,44 +74,105 @@ class _ChannelScreenState extends State<ChannelScreen>
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(err,
style: const TextStyle(fontSize: 11), textAlign: TextAlign.center),
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)),
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 creds = await _api.getLivekitToken(widget.channel.id);
final token = await AuthService().getToken();
if (!mounted) return;
if (creds == null) {
if (token == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('دریافت توکن ناموفق بود',
style: TextStyle(fontSize: 11), textAlign: TextAlign.center),
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)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
return;
}
await _ptt.connect(creds.url, creds.token);
await _ptt.connectToGroup(
widget.channel.id,
token,
AppConfig.livekitUrl,
);
}
@override
void dispose() {
WakelockPlus.disable();
_gyroSub?.cancel();
_ptt.dispose();
_pulseCtrl.dispose();
super.dispose();
@ -105,8 +180,10 @@ class _ChannelScreenState extends State<ChannelScreen>
Future<void> _onPttTap() async {
if (_state == PttState.connected) {
HapticFeedback.heavyImpact();
await _ptt.startSpeaking();
} else if (_state == PttState.speaking) {
HapticFeedback.mediumImpact();
await _ptt.stopSpeaking();
}
}
@ -116,60 +193,90 @@ class _ChannelScreenState extends State<ChannelScreen>
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Stack(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
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),
// 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),
// Main content
Column(
// 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: [
// 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,
),
// 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,
),
),
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),
// 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,
),
),
),
),
const SizedBox(height: 14),
// Status text
_StatusText(state: _state, speaker: _speaker),
],
],
),
],
@ -189,26 +296,18 @@ class _PttButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final (color, icon, label) = switch (state) {
PttState.speaking => (
const Color(0xFFFF1744),
Icons.mic,
'TALKING'
),
PttState.speaking => (const Color(0xFFFF1744), Icons.mic, 'TALKING'),
PttState.receiving => (
const Color(0xFF2979FF),
Icons.volume_up,
speaker ?? '...',
),
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,
'...',
),
const Color(0xFF00C853),
Icons.settings_input_antenna,
'PUSH',
),
PttState.idle => (const Color(0xFF424242), Icons.wifi_off, '...'),
};
final bool enabled =
@ -216,24 +315,24 @@ class _PttButton extends StatelessWidget {
return AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: 130,
height: 130,
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 ? 24 : 10,
spreadRadius: state == PttState.speaking ? 6 : 2,
blurRadius: state == PttState.speaking ? 20 : 8,
spreadRadius: state == PttState.speaking ? 4 : 2,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: Colors.white, size: 38),
const SizedBox(height: 4),
Icon(icon, color: Colors.white, size: 32),
const SizedBox(height: 3),
Text(
label,
textAlign: TextAlign.center,
@ -242,7 +341,7 @@ class _PttButton extends StatelessWidget {
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
fontSize: 10,
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,
);
}
}

View File

@ -11,7 +11,8 @@ class LoginScreen extends StatefulWidget {
}
class _LoginScreenState extends State<LoginScreen> {
final _tokenCtrl = TextEditingController();
final _usernameCtrl = TextEditingController();
final _secretCtrl = TextEditingController();
final _authService = AuthService();
late final ApiService _api;
bool _loading = false;
@ -25,14 +26,16 @@ class _LoginScreenState extends State<LoginScreen> {
@override
void dispose() {
_tokenCtrl.dispose();
_usernameCtrl.dispose();
_secretCtrl.dispose();
super.dispose();
}
Future<void> _login() async {
final token = _tokenCtrl.text.trim();
if (token.isEmpty) {
setState(() => _error = 'توکن را وارد کنید');
final username = _usernameCtrl.text.trim();
final secret = _secretCtrl.text.trim();
if (username.isEmpty || secret.isEmpty) {
setState(() => _error = 'نام کاربری و کلید را وارد کنید');
return;
}
setState(() {
@ -40,7 +43,7 @@ class _LoginScreenState extends State<LoginScreen> {
_error = null;
});
final ok = await _api.login(token);
final ok = await _api.login(username, secret);
if (!mounted) return;
if (ok) {
@ -51,7 +54,7 @@ class _LoginScreenState extends State<LoginScreen> {
} else {
setState(() {
_loading = false;
_error = 'توکن نادرست است';
_error = 'نام کاربری یا کلید نادرست است';
});
}
}
@ -61,116 +64,153 @@ class _LoginScreenState extends State<LoginScreen> {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF00C853).withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(
Icons.settings_input_antenna,
color: Color(0xFF00C853),
size: 22,
),
),
const SizedBox(height: 6),
const Text(
'WalkieTalkie',
style: TextStyle(
color: Colors.white,
fontSize: 13,
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,
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
MediaQuery.of(context).padding.top -
MediaQuery.of(context).padding.bottom,
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
// Icon
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: const Color(0xFF00C853).withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(
Icons.settings_input_antenna,
color: Color(0xFF00C853),
size: 18,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 0),
),
onSubmitted: (_) => _login(),
),
),
// Error
SizedBox(
height: 18,
child: _error != null
? 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,
),
],
const SizedBox(height: 4),
const Text(
'WalkieTalkie',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
child: _loading
? const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
)
: const Icon(Icons.login, color: Colors.white, size: 26),
),
const SizedBox(height: 10),
// Username input
SizedBox(
height: 32,
child: TextField(
controller: _usernameCtrl,
style: const TextStyle(color: Colors.white, fontSize: 11),
textAlign: TextAlign.center,
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: (_) =>
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),
),
],
),
),
),
),

View File

@ -1,9 +1,11 @@
// Expected API:
// POST /api/auth/login body: {"token":"..."} {"user_id":"..."}
// GET /api/channels header: Authorization: Bearer TOKEN
// [{"id":"1","name":"Alpha","member_count":3}]
// POST /api/channels/join body: {"key":"..."} {"id":"...","name":"..."}
// GET /api/channels/{id}/livekit-token {"url":"wss://...","token":"eyJ..."}
// Backend API endpoints:
// POST /auth/login body: {"username":"...","secret":"..."}
// {"access_token":"...","token_type":"bearer"}
// GET /groups/me header: Authorization: Bearer TOKEN
// [{"id":"uuid","name":"...","description":"...","is_active":true}]
//
// LiveKit token از طریق WebSocket دریافت میشود:
// WS /ws/groups/{id}?token={jwt}
import 'dart:convert';
import 'package:http/http.dart' as http;
@ -11,20 +13,13 @@ import '../config/app_config.dart';
import '../models/channel.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)
const _fakeChannels = [
{'id': '1', 'name': 'تیم آلفا', 'member_count': 4},
{'id': '2', 'name': 'پشتیبانی میدانی', 'member_count': 2},
{'id': '3', 'name': 'فرماندهی', 'member_count': 7},
{'id': '4', 'name': 'گروه لجستیک', 'member_count': 3},
{'id': '5', 'name': 'واحد امنیت', 'member_count': 5},
{'id': '1', 'name': 'تیم آلفا'},
{'id': '2', 'name': 'پشتیبانی میدانی'},
{'id': '3', 'name': 'فرماندهی'},
{'id': '4', 'name': 'گروه لجستیک'},
{'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) {
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;
}
try {
final res = await http
.post(
Uri.parse('${AppConfig.baseUrl}/api/auth/login'),
Uri.parse('${AppConfig.baseUrl}/auth/login'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'token': token}),
body: jsonEncode({'username': username, 'secret': secret}),
)
.timeout(const Duration(seconds: 10));
if (res.statusCode == 200) {
final data = jsonDecode(res.body) as Map<String, dynamic>;
await _auth.saveAuth(
token: token,
userId: data['user_id']?.toString(),
);
final accessToken = data['access_token'] as String?;
if (accessToken == null) return false;
final userId = _extractUserIdFromJwt(accessToken);
await _auth.saveAuth(token: accessToken, userId: userId);
return true;
}
return false;
@ -82,7 +96,7 @@ class ApiService {
try {
final res = await http
.get(
Uri.parse('${AppConfig.baseUrl}/api/channels'),
Uri.parse('${AppConfig.baseUrl}/users/me/groups'),
headers: await _headers(),
)
.timeout(const Duration(seconds: 10));
@ -98,66 +112,4 @@ class ApiService {
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;
}
}
}

View File

@ -1,10 +1,27 @@
// LiveKit PTT protocol:
// connect(url, token) join LiveKit room (muted by default)
// startSpeaking() enable microphone LiveKit publishes audio track
// stopSpeaking() disable microphone LiveKit unpublishes track
// Remote audio is played automatically by LiveKit SDK
// ActiveSpeakersChangedEvent fires whenever a participant starts/stops speaking
// PTT Protocol (WebSocket + LiveKit):
//
// 1. connectToGroup(groupId, jwtToken, livekitUrl)
// WS /ws/groups/{groupId}?token={jwtToken}
// دریافت {"type":"livekit_token","token":"..."} از سرور
// اتصال به 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:convert';
import 'dart:io';
import 'package:livekit_client/livekit_client.dart';
@ -13,9 +30,16 @@ import '../config/app_config.dart';
enum PttState { idle, connected, speaking, receiving }
class PttService {
WebSocket? _ws;
Room? _room;
EventsListener<RoomEvent>? _listener;
String? _livekitUrl;
// completers برای دریافت async پیامهای WS
Completer<String?>? _listenerTokenCompleter;
Completer<String?>? _speakGrantCompleter;
final _stateCtrl = StreamController<PttState>.broadcast();
final _speakerCtrl = StreamController<String?>.broadcast();
final _errorCtrl = StreamController<String>.broadcast();
@ -34,15 +58,123 @@ class PttService {
_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) {
await Future.delayed(const Duration(milliseconds: 400));
_setState(PttState.connected);
return true;
}
_livekitUrl = livekitUrl;
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(
roomOptions: const RoomOptions(
adaptiveStream: false,
@ -56,29 +188,25 @@ class PttService {
(e) => _onSpeakersChanged(e.speakers),
)
..on<RoomDisconnectedEvent>((_) {
_setState(PttState.idle);
if (_state != PttState.speaking) _setState(PttState.idle);
});
await _room!.connect(url, token);
// Start muted mic only enabled when PTT is pressed
await _room!.localParticipant?.setMicrophoneEnabled(false);
_setState(PttState.connected);
if (setConnectedState) _setState(PttState.connected);
return true;
} catch (e) {
_errorCtrl.add('اتصال به سرور برقرار نشد');
} catch (_) {
_errorCtrl.add('اتصال به سرور صوتی برقرار نشد');
return false;
}
}
// Speakers detection
// تشخیص متکلم
void _onSpeakersChanged(List<Participant> speakers) {
// Ignore while I'm the one speaking
if (_state == PttState.speaking) return;
final remoteSpeakers =
speakers.whereType<RemoteParticipant>().toList();
final remoteSpeakers = speakers.whereType<RemoteParticipant>().toList();
if (remoteSpeakers.isNotEmpty) {
final p = remoteSpeakers.first;
@ -92,7 +220,7 @@ class PttService {
}
}
// PTT
// PTT: شروع صحبت
Future<void> startSpeaking() async {
if (_state != PttState.connected) return;
@ -101,14 +229,35 @@ class PttService {
return;
}
_speakGrantCompleter = Completer<String?>();
_ws?.add(jsonEncode({'type': 'request_speak'}));
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);
_setState(PttState.speaking);
} catch (_) {
_errorCtrl.add('خطا در فعال‌سازی میکروفون');
} finally {
_speakGrantCompleter = null;
}
}
// PTT: پایان صحبت
Future<void> stopSpeaking() async {
if (_state != PttState.speaking) return;
@ -120,10 +269,10 @@ class PttService {
try {
await _room?.localParticipant?.setMicrophoneEnabled(false);
_setState(PttState.connected);
} catch (_) {
_setState(PttState.connected);
}
} catch (_) {}
_ws?.add(jsonEncode({'type': 'stop_speak'}));
_setState(PttState.connected);
}
// Debug helper
@ -141,7 +290,7 @@ class PttService {
_setState(PttState.connected);
}
// Disconnect / Dispose
// قطع اتصال
Future<void> disconnect() async {
if (AppConfig.debug) {
_setState(PttState.idle);
@ -156,6 +305,10 @@ class PttService {
await _room?.disconnect();
_room = null;
_listener = null;
await _ws?.close();
_ws = null;
_setState(PttState.idle);
}

View File

@ -9,12 +9,16 @@ import connectivity_plus
import device_info_plus
import flutter_webrtc
import livekit_client
import package_info_plus
import shared_preferences_foundation
import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
}

View File

@ -368,6 +368,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -528,6 +544,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -677,6 +709,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:

View File

@ -14,6 +14,8 @@ dependencies:
path_provider: ^2.1.5
shared_preferences: ^2.5.4
http: ^1.6.0
sensors_plus: ^7.0.0
wakelock_plus: ^1.4.0
dev_dependencies:
flutter_test: