diff --git a/Front/android/build.gradle.kts b/Front/android/build.gradle.kts index 4fbdca0..313e58f 100644 --- a/Front/android/build.gradle.kts +++ b/Front/android/build.gradle.kts @@ -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 { - 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("clean") { diff --git a/Front/android/build/reports/problems/problems-report.html b/Front/android/build/reports/problems/problems-report.html index b33da1b..a771701 100644 --- a/Front/android/build/reports/problems/problems-report.html +++ b/Front/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/Front/android/gradle.properties b/Front/android/gradle.properties index fbee1d8..809cb22 100644 --- a/Front/android/gradle.properties +++ b/Front/android/gradle.properties @@ -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] \ No newline at end of file diff --git a/Front/android/settings.gradle.kts b/Front/android/settings.gradle.kts index b744947..25ae424 100644 --- a/Front/android/settings.gradle.kts +++ b/Front/android/settings.gradle.kts @@ -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") diff --git a/Front/lib/config/app_config.dart b/Front/lib/config/app_config.dart index 4fc6002..e891235 100644 --- a/Front/lib/config/app_config.dart +++ b/Front/lib/config/app_config.dart @@ -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'; } diff --git a/Front/lib/screens/channel_list_screen.dart b/Front/lib/screens/channel_list_screen.dart index ad3eb3b..5f8992f 100644 --- a/Front/lib/screens/channel_list_screen.dart +++ b/Front/lib/screens/channel_list_screen.dart @@ -63,139 +63,6 @@ class _ChannelListScreenState extends State { ); } - Future _showPrivateJoin() async { - final keyCtrl = TextEditingController(); - bool joining = false; - String? joinError; - - await showDialog( - 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 { ) : 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), - ], - ), - ), - ); - } -} diff --git a/Front/lib/screens/channel_screen.dart b/Front/lib/screens/channel_screen.dart index 5db262b..aaa7efd 100644 --- a/Front/lib/screens/channel_screen.dart +++ b/Front/lib/screens/channel_screen.dart @@ -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 with SingleTickerProviderStateMixin { late final PttService _ptt; - late final ApiService _api; PttState _state = PttState.idle; String? _speaker; @@ -25,20 +29,30 @@ class _ChannelScreenState extends State late final AnimationController _pulseCtrl; late final Animation _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? _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(begin: 1.0, end: 1.12).animate( - CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut), - ); + _pulseAnim = Tween( + 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 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 _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 _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 Future _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 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, - ); - } -} diff --git a/Front/lib/screens/login_screen.dart b/Front/lib/screens/login_screen.dart index 08557d7..aab5f6b 100644 --- a/Front/lib/screens/login_screen.dart +++ b/Front/lib/screens/login_screen.dart @@ -11,7 +11,8 @@ class LoginScreen extends StatefulWidget { } class _LoginScreenState extends State { - 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 { @override void dispose() { - _tokenCtrl.dispose(); + _usernameCtrl.dispose(); + _secretCtrl.dispose(); super.dispose(); } Future _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 { _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 { } else { setState(() { _loading = false; - _error = 'توکن نادرست است'; + _error = 'نام کاربری یا کلید نادرست است'; }); } } @@ -61,116 +64,153 @@ class _LoginScreenState extends State { 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), - ), - ], + ), ), ), ), diff --git a/Front/lib/services/api_service.dart b/Front/lib/services/api_service.dart index 246f1fa..beb730f 100644 --- a/Front/lib/services/api_service.dart +++ b/Front/lib/services/api_service.dart @@ -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 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; + return map['sub']?.toString(); + } catch (_) { + return null; + } + } + + Future 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; - 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 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); - } - return null; - } catch (_) { - return null; - } - } - - Future 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; - return LivekitCredentials( - url: data['url'] as String, - token: data['token'] as String, - ); - } - return null; - } catch (_) { - return null; - } - } } diff --git a/Front/lib/services/ptt_service.dart b/Front/lib/services/ptt_service.dart index e98d889..067025d 100644 --- a/Front/lib/services/ptt_service.dart +++ b/Front/lib/services/ptt_service.dart @@ -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? _listener; + String? _livekitUrl; + + // completers برای دریافت async پیام‌های WS + Completer? _listenerTokenCompleter; + Completer? _speakGrantCompleter; + final _stateCtrl = StreamController.broadcast(); final _speakerCtrl = StreamController.broadcast(); final _errorCtrl = StreamController.broadcast(); @@ -34,15 +58,123 @@ class PttService { _stateCtrl.add(s); } - // ── Connect ────────────────────────────────────────────────────────────── - Future connect(String url, String token) async { + // ── اتصال به گروه ──────────────────────────────────────────────────────── + Future 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(); + + _ws!.listen( + (data) { + if (data is String) { + final msg = jsonDecode(data) as Map; + _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 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 _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((_) { - _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 speakers) { - // Ignore while I'm the one speaking if (_state == PttState.speaking) return; - final remoteSpeakers = - speakers.whereType().toList(); + final remoteSpeakers = speakers.whereType().toList(); if (remoteSpeakers.isNotEmpty) { final p = remoteSpeakers.first; @@ -92,7 +220,7 @@ class PttService { } } - // ── PTT ─────────────────────────────────────────────────────────────────── + // ── PTT: شروع صحبت ──────────────────────────────────────────────────────── Future startSpeaking() async { if (_state != PttState.connected) return; @@ -101,14 +229,35 @@ class PttService { return; } + _speakGrantCompleter = Completer(); + _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 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 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); } diff --git a/Front/macos/Flutter/GeneratedPluginRegistrant.swift b/Front/macos/Flutter/GeneratedPluginRegistrant.swift index 07734ef..84eb671 100644 --- a/Front/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/Front/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/Front/pubspec.lock b/Front/pubspec.lock index ddcff60..28961c5 100644 --- a/Front/pubspec.lock +++ b/Front/pubspec.lock @@ -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: diff --git a/Front/pubspec.yaml b/Front/pubspec.yaml index 22832af..8316d56 100644 --- a/Front/pubspec.yaml +++ b/Front/pubspec.yaml @@ -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: