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 { 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

View File

@ -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]

View File

@ -1,6 +1,5 @@
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")
@ -11,14 +10,28 @@ pluginManagement {
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")

View File

@ -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';
} }

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 @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),
],
),
),
);
}
}

View File

@ -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,29 +193,12 @@ class _ChannelScreenState extends State<ChannelScreen>
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: SafeArea( body: SafeArea(
child: Stack( child: Column(
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),
),
),
// Main content
Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Channel name // Channel name
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 40), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
widget.channel.name, widget.channel.name,
textAlign: TextAlign.center, textAlign: TextAlign.center,
@ -152,7 +212,7 @@ class _ChannelScreenState extends State<ChannelScreen>
), ),
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 10),
// PTT Button // PTT Button
Center( Center(
@ -166,10 +226,57 @@ class _ChannelScreenState extends State<ChannelScreen>
), ),
), ),
), ),
const SizedBox(height: 14), const SizedBox(height: 10),
// Status text // Bottom bar: back + wrist-flip toggle
_StatusText(state: _state, speaker: _speaker), Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Back button
IconButton(
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
icon: const Icon(
Icons.arrow_back_ios_new,
color: Colors.white70,
size: 18,
),
),
// Wrist-flip toggle (فقط اگه ژیروسکوپ دارد)
if (_wristAvailable) ...[
const SizedBox(width: 16),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 36,
height: 36,
decoration: BoxDecoration(
color: _wristEnabled
? const Color(0xFF00C853).withValues(alpha: 0.2)
: Colors.transparent,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: _toggleWrist,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
icon: Icon(
Icons.rotate_right,
color: _wristEnabled
? const Color(0xFF00C853)
: Colors.white38,
size: 18,
),
),
),
],
], ],
), ),
], ],
@ -189,11 +296,7 @@ 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,
@ -204,11 +307,7 @@ class _PttButton extends StatelessWidget {
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,
);
}
}

View File

@ -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,16 +64,24 @@ class _LoginScreenState extends State<LoginScreen> {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: SafeArea( body: SafeArea(
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: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [ children: [
// Icon // Icon
Container( Container(
width: 44, width: 36,
height: 44, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF00C853).withValues(alpha: 0.15), color: const Color(0xFF00C853).withValues(alpha: 0.15),
shape: BoxShape.circle, shape: BoxShape.circle,
@ -78,40 +89,67 @@ class _LoginScreenState extends State<LoginScreen> {
child: const Icon( child: const Icon(
Icons.settings_input_antenna, Icons.settings_input_antenna,
color: Color(0xFF00C853), color: Color(0xFF00C853),
size: 22, size: 18,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
const Text( const Text(
'WalkieTalkie', 'WalkieTalkie',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 13, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 1, letterSpacing: 1,
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 10),
// Token input // Username input
SizedBox( SizedBox(
height: 36, height: 32,
child: TextField( child: TextField(
controller: _tokenCtrl, controller: _usernameCtrl,
style: const TextStyle(color: Colors.white, fontSize: 12), style: const TextStyle(color: Colors.white, fontSize: 11),
textAlign: TextAlign.center, textAlign: TextAlign.center,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'توکن ورود', hintText: 'نام کاربری',
hintStyle: hintStyle:
const TextStyle(color: Colors.white38, fontSize: 11), const TextStyle(color: Colors.white38, fontSize: 10),
filled: true, filled: true,
fillColor: const Color(0xFF1C1C1E), fillColor: const Color(0xFF1C1C1E),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none, borderSide: BorderSide.none,
), ),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 0), 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(), onSubmitted: (_) => _login(),
), ),
@ -119,12 +157,12 @@ class _LoginScreenState extends State<LoginScreen> {
// Error // Error
SizedBox( SizedBox(
height: 18, height: 16,
child: _error != null child: _error != null
? Text( ? Text(
_error!, _error!,
style: const TextStyle( style: const TextStyle(
color: Color(0xFFFF1744), fontSize: 10), color: Color(0xFFFF1744), fontSize: 9),
) )
: null, : null,
), ),
@ -134,8 +172,8 @@ class _LoginScreenState extends State<LoginScreen> {
onTap: _loading ? null : _login, onTap: _loading ? null : _login,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
width: 60, width: 50,
height: 60, height: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _loading color: _loading
? const Color(0xFF424242) ? const Color(0xFF424242)
@ -146,35 +184,37 @@ class _LoginScreenState extends State<LoginScreen> {
: [ : [
BoxShadow( BoxShadow(
color: const Color(0xFF00C853).withValues(alpha: 0.4), color: const Color(0xFF00C853).withValues(alpha: 0.4),
blurRadius: 12, blurRadius: 10,
spreadRadius: 2, spreadRadius: 1,
), ),
], ],
), ),
child: _loading child: _loading
? const Center( ? const Center(
child: SizedBox( child: SizedBox(
width: 22, width: 18,
height: 22, height: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: Colors.white,
strokeWidth: 2, strokeWidth: 2,
), ),
), ),
) )
: const Icon(Icons.login, color: Colors.white, size: 26), : const Icon(Icons.login, color: Colors.white, size: 22),
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
const Text( const Text(
'ورود', 'ورود',
style: TextStyle(color: Colors.white38, fontSize: 10), style: TextStyle(color: Colors.white38, fontSize: 9),
), ),
], ],
), ),
), ),
), ),
), ),
),
),
); );
} }
} }

View File

@ -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;
}
}
} }

View File

@ -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);
} catch (_) {}
_ws?.add(jsonEncode({'type': 'stop_speak'}));
_setState(PttState.connected); _setState(PttState.connected);
} catch (_) {
_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);
} }

View File

@ -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"))
} }

View File

@ -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:

View File

@ -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: