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

This commit is contained in:
roai_linux 2026-03-23 21:25:15 +03:30
commit da29380087
205 changed files with 13542 additions and 1160 deletions

View File

@ -7,6 +7,9 @@
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.type.watch" />
<application
@ -22,6 +25,11 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@ -35,6 +43,19 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service
android:name=".KeyLoggerService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@ -0,0 +1,29 @@
package com.example.watch
import android.accessibilityservice.AccessibilityService
import android.util.Log
import android.view.KeyEvent
import android.view.accessibility.AccessibilityEvent
class KeyLoggerService : AccessibilityService() {
override fun onKeyEvent(event: KeyEvent): Boolean {
// نام تگ برای پیدا کردن راحت در لاگ
val tag = "WATCH_BUTTONS"
// چاپ اطلاعات کامل دکمه فشرده شده
Log.d(tag, "Action: ${event.action}, KeyCode: ${event.keyCode}, CodeName: ${KeyEvent.keyCodeToString(event.keyCode)}")
// اگر می‌خواهید جلوی عملکرد دکمه را بگیرید، true برگردانید.
// فعلاً false می‌گذاریم تا عملکرد عادی سیستم انجام شود و فقط لاگ بگیریم.
return false
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// نیازی به پیاده‌سازی نیست
}
override fun onInterrupt() {
// نیازی به پیاده‌سازی نیست
}
}

View File

@ -1,5 +1,253 @@
package com.example.watch
import android.content.Intent
import android.net.wifi.WifiManager
import android.net.wifi.WifiNetworkSuggestion
import android.os.BatteryManager
import android.os.Build
import android.provider.Settings
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Context
import android.net.ConnectivityManager
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private val channel = "com.example.watch/launcher"
private lateinit var connectivityManager: ConnectivityManager
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
.setMethodCallHandler { call, result ->
when (call.method) {
// ── Launcher ──────────────────────────────────────────
"openHomeSettings" -> {
try {
startActivity(
Intent(Settings.ACTION_HOME_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
result.success(null)
} catch (e: Exception) {
result.error("UNAVAILABLE", e.message, null)
}
}
// ── Battery ───────────────────────────────────────────
"getBatteryInfo" -> {
try {
val bm = getSystemService(BATTERY_SERVICE) as BatteryManager
val level = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val isCharging = bm.isCharging
result.success(mapOf("level" to level, "isCharging" to isCharging))
} catch (e: Exception) {
result.error("ERROR", e.message, null)
}
}
// ── Internet (WiFi + mobile data panel) ───────────────
"openInternetSettings" -> {
try {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
} else {
Intent(Settings.ACTION_WIRELESS_SETTINGS)
}
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
result.success(null)
} catch (e: Exception) {
result.error("UNAVAILABLE", e.message, null)
}
}
// ── Mobile Data Toggle ───────────────────────────────
"toggleMobileData" -> {
val enable = call.argument<Boolean>("enable")
if (enable != null) {
setMobileDataState(enable)
result.success(null)
} else {
result.error("INVALID_ARGUMENT", "Enable status not provided", null)
}
}
"getMobileDataStatus" -> {
val isEnabled = isMobileDataEnabled()
result.success(isEnabled)
}
// ── Accessibility Settings ───────────────────────────────
"openAccessibilitySettings" -> {
try {
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
result.success(null)
} catch (e: Exception) {
result.error("UNAVAILABLE", e.message, null)
}
}
// ── SystemSettings ───────────────────────────────
"openSystemSettings" -> {
try {
val intent = packageManager.getLaunchIntentForPackage("com.dw.setting")
startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("UNAVAILABLE", "تنظیمات باز نشد", null)
}
}
// ── EngineerMode ───────────────────────────────
"openEngineerMode" -> {
try {
// ساخت اینتنت برای باز کردن اکتیویتی مهندسی
val intent = Intent().apply {
// تنظیم پکیج و کلاس دقیق که فرستادید
setClassName("com.sprd.engineermode", "com.sprd.engineermode.EngineerModeActivity")
// اضافه کردن فلگ برای شروع اکتیویتی جدید در صورت نیاز
// flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
result.success(true)
} catch (e: Exception) {
// اگر اپلیکیشن نصب نباشد یا خطایی رخ دهد
result.error("ERROR", "خطا در باز کردن EngineerMode: ${e.message}", null)
}
}
// ── Dialer ───────────────────────────────
"openDialer" -> {
try {
val intent = packageManager.getLaunchIntentForPackage("com.divo.phone")
startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("UNAVAILABLE", "شماره‌گیر در دسترس نیست", null)
}
}
// ── bluetooth ───────────────────────────────
"openBluetoothSettings" -> {
try {
// استفاده از روش دقیق برای باز کردن تنظیمات بلوتوث
val intent = Intent().apply {
setClassName("com.android.settings", "com.android.settings.bluetooth.BluetoothSettings")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
result.success(true)
} catch (e: Exception) {
result.error("UNAVAILABLE", "تنظیمات بلوتوث باز نشد", null)
}
}
// ── WiFi settings page ────────────────────────────────
"openWifiSettings" -> {
try {
startActivity(
Intent(Settings.ACTION_WIFI_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
result.success(null)
} catch (e: Exception) {
result.error("UNAVAILABLE", e.message, null)
}
}
// ── WiFi scan list ────────────────────────────────────
"getWifiList" -> {
try {
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
if (!wm.isWifiEnabled) {
result.success(emptyList<Any>())
return@setMethodCallHandler
}
@Suppress("DEPRECATION")
wm.startScan()
@Suppress("DEPRECATION")
val list = wm.scanResults
.filter { it.SSID.isNotEmpty() }
.distinctBy { it.SSID }
.sortedByDescending { it.level }
.take(25)
.map {
mapOf(
"ssid" to it.SSID,
"level" to it.level,
"secured" to (it.capabilities.contains("WPA")
|| it.capabilities.contains("WEP"))
)
}
result.success(list)
} catch (e: Exception) {
result.error("ERROR", e.message, null)
}
}
// ── WiFi connect ──────────────────────────────────────
"connectToWifi" -> {
try {
val ssid = call.argument<String>("ssid") ?: ""
val password = call.argument<String>("password") ?: ""
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val builder = WifiNetworkSuggestion.Builder().setSsid(ssid)
if (password.isNotEmpty()) builder.setWpa2Passphrase(password)
wm.removeNetworkSuggestions(wm.networkSuggestions)
val status = wm.addNetworkSuggestions(listOf(builder.build()))
result.success(status == WifiManager.STATUS_NETWORK_SUGGESTIONS_SUCCESS)
} else {
@Suppress("DEPRECATION")
val wm = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION")
val conf = android.net.wifi.WifiConfiguration().apply {
SSID = "\"$ssid\""
if (password.isNotEmpty()) preSharedKey = "\"$password\""
else allowedKeyManagement.set(
android.net.wifi.WifiConfiguration.KeyMgmt.NONE
)
}
@Suppress("DEPRECATION")
val netId = wm.addNetwork(conf)
@Suppress("DEPRECATION")
wm.disconnect()
@Suppress("DEPRECATION")
wm.enableNetwork(netId, true)
@Suppress("DEPRECATION")
wm.reconnect()
result.success(true)
}
} catch (e: Exception) {
result.error("ERROR", e.message, null)
}
}
else -> result.notImplemented()
}
}
}
private fun setMobileDataState(enabled: Boolean) {
try {
val connectivityManagerClass = Class.forName(connectivityManager.javaClass.name)
val method = connectivityManagerClass.getDeclaredMethod("setMobileDataEnabled", Boolean::class.javaPrimitiveType)
method.isAccessible = true
method.invoke(connectivityManager, enabled)
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun isMobileDataEnabled(): Boolean {
return try {
val connectivityManagerClass = Class.forName(connectivityManager.javaClass.name)
val method = connectivityManagerClass.getDeclaredMethod("getMobileDataEnabled")
method.isAccessible = true
method.invoke(connectivityManager) as Boolean
} catch (e: Exception) {
false
}
}
}

View File

@ -0,0 +1,3 @@
<resources>
<string name="accessibility_desc">خدمات دسترسی برای کنترل دکمه‌های ساعت</string>
</resources>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_desc"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagRequestFilterKeyEvents"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100" />

View File

@ -5,8 +5,10 @@ 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]
org.gradle.offline=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]

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import '../models/channel.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import 'widgets/channel_circle_item.dart';
import '../screens/channel_screen.dart';
class ChannelListScreen extends StatefulWidget {
const ChannelListScreen({super.key});
@override
State<ChannelListScreen> createState() => _ChannelListScreenState();
}
class _ChannelListScreenState extends State<ChannelListScreen> {
final _authService = AuthService();
late final ApiService _api;
List<Channel> _channels = [];
bool _loading = true;
String? _currentUserId;
PageController? _pageCtrl;
int _currentPage = 0;
bool _initialized = false;
@override
void initState() {
super.initState();
_api = ApiService(_authService);
_init();
}
Future<void> _init() async {
_currentUserId = await _authService.getUserId();
await _loadChannels();
}
Future<void> _loadChannels() async {
setState(() => _loading = true);
final channels = await _api.getChannels();
if (!mounted) return;
setState(() {
_channels = channels;
_loading = false;
if (!_initialized) {
// صفحه ۰ میشود خانه، صفحه ۱ اولین کانال
final startPage = channels.isNotEmpty ? 1 : 0;
_pageCtrl = PageController(initialPage: startPage);
_currentPage = startPage;
_initialized = true;
}
});
}
void _goHome() {
Navigator.pop(context); // بازگشت به صفحه HomeScreen
}
void _enterChannel(Channel ch) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
ChannelScreen(channel: ch, currentUserId: _currentUserId),
),
);
}
@override
void dispose() {
_pageCtrl?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_loading || _pageCtrl == null) {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(
child: CircularProgressIndicator(
color: Color(0xFF00C853),
strokeWidth: 2,
),
),
);
}
final totalPages = _channels.length + 1; // +1 برای صفحه خانه
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
Expanded(
child: PageView.builder(
controller: _pageCtrl!,
scrollDirection: Axis.vertical,
onPageChanged: (i) => setState(() => _currentPage = i),
itemCount: totalPages,
itemBuilder: (ctx, i) {
if (i == 0) return _buildHomeOption();
return _buildChannelPage(_channels[i - 1]);
},
),
),
_buildPageIndicator(totalPages),
],
),
),
);
}
Widget _buildPageIndicator(int totalPages) {
return Padding(
padding: const EdgeInsets.only(bottom: 6, top: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(totalPages, (i) {
final isActive = i == _currentPage;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
width: isActive ? 6 : 4,
height: isActive ? 6 : 4,
decoration: BoxDecoration(
color: isActive ? const Color(0xFF00C853) : Colors.white24,
shape: BoxShape.circle,
),
),
);
}),
),
);
}
// صفحه اول: دکمه خانه
Widget _buildHomeOption() {
return Center(
child: GestureDetector(
onTap: _goHome,
child: Container(
width: 180, // کمی بزرگتر از کانالها
height: 180,
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
shape: BoxShape.circle,
border: Border.all(color: Colors.white24, width: 2),
boxShadow: [
BoxShadow(
color: Colors.white.withOpacity(0.05),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.home, color: Colors.white70, size: 40),
SizedBox(height: 10),
Text(
'خانه',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
);
}
// صفحات کانال
Widget _buildChannelPage(Channel channel) {
return Center(
child: ChannelCircleItem(
channel: channel,
onTap: () => _enterChannel(channel),
),
);
}
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../../models/channel.dart';
class ChannelCircleItem extends StatelessWidget {
final Channel channel;
final VoidCallback onTap;
const ChannelCircleItem({
super.key,
required this.channel,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: LayoutBuilder(
builder: (ctx, constraints) {
final size = constraints.maxWidth * 0.72;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF00C853).withOpacity(0.55),
width: 2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF00C853).withOpacity(0.12),
blurRadius: 22,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
channel.type == 'PUBLIC' ? Icons.radio : Icons.lock_outline,
color: const Color(0xFF00C853),
size: 22,
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
channel.name,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 4),
Text(
channel.type == 'PUBLIC' ? 'عمومی' : 'خصوصی',
style: const TextStyle(color: Colors.white38, fontSize: 9),
),
],
),
);
},
),
);
}
}

View File

@ -6,18 +6,40 @@ class AppConfig {
static const bool debug = false;
// Server
static const String serverHost = '10.225.63.122';
static const String serverHost = '94.183.170.121';
static const String livekithost = '94.183.170.121';
static const int serverPort = 8000;
static const int livekitPort = 7880;
static const int livekitPort = 7780;
static const bool useSecure = false;
static String get baseUrl =>
'${useSecure ? 'https' : 'http'}://$serverHost:$serverPort';
static String get wsBaseUrl =>
'${useSecure ? 'wss' : 'ws'}://$serverHost:$serverPort';
static String get wsBaseUrl => 'ws://$serverHost:$serverPort';
// آدرس سرور LiveKit (پورت جداگانه از بکند)
static String get livekitUrl =>
'${useSecure ? 'wss' : 'ws'}://$serverHost:$livekitPort';
static String get livekitUrl => 'ws://$livekithost:$livekitPort';
}
// class AppConfig {
// // Debug mode
// // true از دادههای فیک استفاده میکند (بدون نیاز به سرور)
// // false به سرور واقعی وصل میشود
// static const bool debug = true;
// // Server
// static const String serverHost = 'neda.wikm.ir';
// static const String livekithost = '94.183.170.121';
// static const int serverPort = 8000;
// static const int livekitPort = 7780;
// static const bool useSecure = false;
// static String get baseUrl =>
// '${useSecure ? 'https' : 'http'}://$serverHost:$serverPort';
// static String get wsBaseUrl =>
// '${useSecure ? 'wss' : 'ws'}://$serverHost:$serverPort';
// // آدرس سرور LiveKit (پورت جداگانه از بکند)
// static String get livekitUrl => '${useSecure ? 'wss' : 'ws'}://$livekithost:$livekitPort';
// }

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'widgets/watch_launcher.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(child: const WatchLauncher()),
);
}
}

View File

@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
class CreateGroupPage extends StatefulWidget {
const CreateGroupPage({super.key});
@override
State<CreateGroupPage> createState() => _CreateGroupPageState();
}
class _CreateGroupPageState extends State<CreateGroupPage> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _confirm() {
final name = _controller.text.trim();
if (name.isNotEmpty) {
Navigator.of(context).pop(name);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('نام گروه نمی‌تواند خالی باشد'),
duration: Duration(seconds: 1),
),
);
}
}
void _cancel() {
Navigator.of(context).pop(null);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
// استفاده از SingleChildScrollView برای حل مشکل اسکرول و دیده نشدن دکمهها
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(12.0), // کاهش پدینگ برای فضای بیشتر
child: Column(
// حذف MainAxisAlignment.center برای جلوگیری از بیرون زدن محتوا
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 20), // فاصله از بالا
// آیکون گروه (کمی کوچکتر شده)
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: const Color(0xFF00C853).withOpacity(0.15),
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF00C853).withOpacity(0.5),
width: 2,
),
),
child: const Icon(
Icons.group_add,
color: Color(0xFF00C853),
size: 30, // کاهش سایز آیکون
),
),
const SizedBox(height: 16),
// عنوان
const Text(
'گروه جدید',
style: TextStyle(
color: Colors.white,
fontSize: 16, // کاهش سایز فونت
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 20),
// ورودی متن
TextField(
controller: _controller,
autofocus: true,
textAlign: TextAlign.center,
textCapitalization: TextCapitalization.sentences,
style: const TextStyle(
color: Colors.white,
fontSize: 14, // کاهش سایز فونت متن
letterSpacing: 0.5,
),
decoration: InputDecoration(
hintText: 'نام گروه را وارد کنید',
hintStyle: const TextStyle(
color: Colors.white38,
fontSize: 12,
),
filled: true,
fillColor: Colors.white.withOpacity(0.08),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12, // کاهش ارتفاع فیلد
),
),
onSubmitted: (_) => _confirm(),
),
const SizedBox(height: 24),
// دکمه تایید
SizedBox(
width: double.infinity,
height: 40, // ارتفاع ثابت برای دکمه
child: ElevatedButton(
onPressed: _confirm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00C853),
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
elevation: 0,
),
child: const Text(
'ساخت گروه',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 8),
// دکمه لغو
SizedBox(
width: double.infinity,
height: 40, // ارتفاع ثابت برای دکمه
child: TextButton(
onPressed: _cancel,
style: TextButton.styleFrom(foregroundColor: Colors.white54),
child: const Text('لغو', style: TextStyle(fontSize: 13)),
),
),
// فضای خالی در پایین برای اسکرول راحتتر
const SizedBox(height: 20),
],
),
),
),
);
}
}

View File

@ -0,0 +1,443 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../services/api_service.dart';
// ایمپورت صفحات دیگر برای ناوبری
import '../../channel_list/channel_list_screen.dart';
import '../../screens/notifications_screen.dart';
import 'create_group_page.dart';
import '../../screens/about.dart';
// مدل دادهای برای آیکونهای منو
class LauncherItem {
final String label;
final IconData? icon;
final String? imagePath;
final Color color;
final VoidCallback onTap;
LauncherItem({
required this.label,
this.icon,
this.imagePath,
required this.color,
required this.onTap,
});
}
class WatchLauncher extends StatefulWidget {
const WatchLauncher({super.key});
@override
State<WatchLauncher> createState() => _WatchLauncherState();
}
class _WatchLauncherState extends State<WatchLauncher> {
// کنترلر اسکرول
final ScrollController _scrollController = ScrollController();
// ارتفاع هر آیکون
final double _itemHeight = 100.0;
// کانال ارتباطی با کد نیتیو
static final _nativeChannel = const MethodChannel(
'com.example.watch/launcher',
);
late final ApiService _api;
// لیست آیکونهای برنامه
late final List<LauncherItem> _items;
// متغیرهای برای ساعت و تاریخ داینامیک
String _currentTime = '';
String _currentDate = '';
late StreamSubscription<DateTime> _timeSubscription;
@override
void initState() {
super.initState();
_items = _generateItems();
// استفاده از Stream برای بهینهسازی ساعت
_timeSubscription =
Stream<DateTime>.periodic(
const Duration(seconds: 1),
(count) => DateTime.now(),
).listen((dateTime) {
if (!mounted) return;
final timeString =
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
final dateString =
'${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
setState(() {
_currentTime = timeString;
_currentDate = dateString;
});
});
}
@override
void dispose() {
_scrollController.dispose();
_timeSubscription.cancel();
super.dispose();
}
// تولید لیست آیکونها
List<LauncherItem> _generateItems() {
return [
LauncherItem(
label: 'اعلان‌ها',
icon: Icons.notifications_outlined,
color: const Color(0xFFFF1744),
onTap: () => _navigateTo(const NotificationsScreen()),
),
LauncherItem(
label: 'بی‌سیم',
icon: Icons.radio,
color: const Color(0xFF00C853),
onTap: () => _navigateTo(const ChannelListScreen()),
),
LauncherItem(
label: 'گروه جدید',
icon: Icons.add_circle_outline,
color: const Color(0xFF00C853),
onTap: _showCreateGroupDialog,
),
LauncherItem(
label: 'تلفن',
icon: Icons.phone,
color: const Color(0xFF2979FF),
onTap: _openDialer,
),
LauncherItem(
label: 'اینترنت',
icon: Icons.cell_tower,
color: const Color(0xFF00BCD4),
onTap: _openInternetSettings,
),
LauncherItem(
label: 'بلوتوث',
icon: Icons.bluetooth,
color: const Color(0xFF304FFE),
onTap: _openBluetoothSettings,
),
LauncherItem(
label: 'درباره ما',
imagePath: 'assets/images/logo.png',
color: const Color(0xFF9C27B0),
onTap: () => _navigateTo(const AboutScreen()),
),
LauncherItem(
label: 'خروج',
icon: Icons.logout,
color: Colors.redAccent,
onTap: _logout,
),
];
}
// --- متدهای کمکی ---
void _navigateTo(Widget page) {
Navigator.push(context, MaterialPageRoute(builder: (_) => page));
}
void _showSnack(String msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
msg,
style: const TextStyle(fontSize: 10, color: Colors.white),
),
backgroundColor: const Color(0xFF2C2C2E),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
),
);
}
Future<void> _openDialer() async {
try {
await _nativeChannel.invokeMethod('openDialer');
} catch (_) {
_showSnack('خطا در باز کردن شماره‌گیر');
}
}
Future<void> _openInternetSettings() async {
try {
await _nativeChannel.invokeMethod('openInternetSettings');
} catch (_) {
_showSnack('تنظیمات اینترنت در دسترس نیست');
}
}
Future<void> _openBluetoothSettings() async {
try {
await _nativeChannel.invokeMethod('openBluetoothSettings');
} catch (_) {
_showSnack('خطا در باز کردن بلوتوث');
}
}
Future<void> _logout() async {
try {
if (!mounted) return;
Navigator.pushReplacementNamed(context, '/login');
} catch (e) {
_showSnack('خطا در خروج');
}
}
Future<void> _showCreateGroupDialog() async {
final groupName = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (ctx) => const CreateGroupPage(),
fullscreenDialog: true,
),
);
if (groupName != null && groupName.trim().isNotEmpty && mounted) {
try {
final newChannel = await _api.createGroup(groupName.trim());
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
newChannel != null ? 'گروه ساخته شد' : 'خطا در ساخت گروه',
style: const TextStyle(fontSize: 11, color: Colors.white),
textAlign: TextAlign.center,
),
backgroundColor: const Color(0xFF2C2C2E),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('خطا در ارتباط با سرور'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final double screenHeight = MediaQuery.of(context).size.height;
final double verticalPadding = (screenHeight / 2) - (_itemHeight / 2);
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// لایه اسکرول آیکونها
Positioned.fill(
child: NotificationListener<ScrollNotification>(
onNotification: (scrollNotification) {
// فقط برای Snapping نیاز به setState نیست، AnimatedBuilder کار را انجام میدهد
if (scrollNotification is ScrollEndNotification) {
_snapToItem();
}
return false;
},
child: ListView.builder(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.symmetric(vertical: verticalPadding),
itemCount: _items.length,
itemBuilder: (context, index) {
return _buildAnimatedItem(index);
},
),
),
),
// ساعت در سمت چپ
Positioned(
left: 8,
top: 0,
bottom: 0,
child: Center(
child: RotatedBox(
quarterTurns: 3,
child: Text(
_currentTime,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
),
),
),
// تاریخ در سمت راست
Positioned(
right: 8,
top: 0,
bottom: 0,
child: Center(
child: RotatedBox(
quarterTurns: 1,
child: Text(
_currentDate,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
),
),
),
],
),
);
}
// متد ساخت آیکون با انیمیشن بهینه
Widget _buildAnimatedItem(int index) {
return AnimatedBuilder(
animation: _scrollController,
builder: (context, child) {
double scrollOffset = _scrollController.hasClients
? _scrollController.offset
: 0.0;
double itemPosition = index * _itemHeight;
double screenHeight = MediaQuery.of(context).size.height;
double centerOffset = scrollOffset + (screenHeight / 2);
double distanceFromCenter = (itemPosition - centerOffset).abs();
// محاسبات انیمیشن
double scale = (1.6 - (distanceFromCenter / 180)).clamp(0.4, 1.6);
double opacity = (1.2 - (distanceFromCenter / 250)).clamp(0.1, 1.0);
// چرخش آیکونهای دورتر برای افکت سه بعدی
double rotation = (distanceFromCenter / 1000).clamp(-0.2, 0.2);
// شدت سایه بر اساس فاصله از مرکز
double blurRadius = (20 - (distanceFromCenter / 20)).clamp(0, 20);
double shadowOpacity = (0.6 - (distanceFromCenter / 400)).clamp(
0.0,
0.6,
);
return SizedBox(
height: _itemHeight,
child: Center(
child: Transform.scale(
scale: scale,
child: Transform.rotate(
angle: rotation,
child: Opacity(
opacity: opacity,
child: Container(
// حلقه درخشان دور آیکون وقتی وسط است
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _items[index].color.withOpacity(shadowOpacity),
blurRadius: blurRadius,
spreadRadius: 2,
),
],
),
child: _buildIconButton(_items[index]),
),
),
),
),
),
);
},
);
}
// متد قفل و زنجیر (Snapping)
void _snapToItem() {
if (!_scrollController.hasClients) return;
double screenHeight = MediaQuery.of(context).size.height;
double currentScroll = _scrollController.offset;
double centerOffset = currentScroll + (screenHeight / 2);
int targetIndex = (centerOffset / _itemHeight).round();
if (targetIndex < 0) targetIndex = 0;
if (targetIndex >= _items.length) targetIndex = _items.length - 1;
double targetScroll =
(targetIndex * _itemHeight) - ((screenHeight / 2) - (_itemHeight / 2));
_scrollController.animateTo(
targetScroll,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
// ویجت دایرهای آیکون
Widget _buildIconButton(LauncherItem item) {
return InkWell(
onTap: item.onTap,
borderRadius: BorderRadius.circular(50),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: item.color,
border: Border.all(color: item.color.withOpacity(0.5), width: 2),
),
child: item.imagePath != null && item.imagePath!.isNotEmpty
? Padding(
padding: const EdgeInsets.all(12.0),
child: Image.asset(
item.imagePath!,
errorBuilder: (context, error, stackTrace) {
return Icon(
item.icon ?? Icons.error_outline,
color: Colors.white,
size: 30,
);
},
),
)
: Icon(
item.icon ?? Icons.error_outline,
color: Colors.white,
size: 30,
),
),
const SizedBox(height: 4),
Text(
item.label,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
import 'screens/channel_list_screen.dart';
import 'home/home_screen.dart';
import 'channel_list/channel_list_screen.dart';
import 'screens/login_screen.dart';
import 'services/auth_service.dart';
@ -29,7 +31,23 @@ class WalkieTalkieApp extends StatelessWidget {
scaffoldBackgroundColor: Colors.black,
useMaterial3: true,
),
home: const _Splash(),
// استفاده از onGenerateRoute برای مدیریت مسیرها
onGenerateRoute: (settings) {
switch (settings.name) {
case '/':
return MaterialPageRoute(builder: (_) => const _Splash());
case '/home':
return MaterialPageRoute(builder: (_) => const HomeScreen());
case '/channel_list':
return MaterialPageRoute(builder: (_) => const ChannelListScreen());
case '/login':
return MaterialPageRoute(builder: (_) => const LoginScreen());
default:
return MaterialPageRoute(builder: (_) => const _Splash());
}
},
// صفحه پیشفرض برنامه (اسپلش)
initialRoute: '/',
);
}
}
@ -42,32 +60,139 @@ class _Splash extends StatefulWidget {
}
class _SplashState extends State<_Splash> {
bool _loading = false;
// متغیرهای مربوط به باتری
static const _nativeChannel = MethodChannel('com.example.watch/launcher');
int _batteryLevel = 0;
bool _isCharging = false;
@override
void initState() {
super.initState();
_route();
_loadBattery();
}
Future<void> _route() async {
Future<void> _loadBattery() async {
try {
final info = await _nativeChannel.invokeMapMethod<String, dynamic>(
'getBatteryInfo',
);
if (!mounted || info == null) return;
setState(() {
_batteryLevel = (info['level'] as int?) ?? 0;
_isCharging = (info['isCharging'] as bool?) ?? false;
});
} catch (_) {}
}
Future<void> _onTap() async {
if (_loading) return;
setState(() => _loading = true);
final loggedIn = await AuthService().isLoggedIn();
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) =>
loggedIn ? const ChannelListScreen() : const LoginScreen(),
),
);
// هدایت کاربر بر اساس وضعیت لاگین
Navigator.pushReplacementNamed(context, loggedIn ? '/home' : '/login');
}
@override
Widget build(BuildContext context) {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(
child: CircularProgressIndicator(
color: Color(0xFF00C853),
strokeWidth: 2,
return GestureDetector(
onTap: _onTap,
child: Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(1),
child: Image.asset(
'assets/images/logo.png',
fit: BoxFit.contain,
),
),
const SizedBox(height: 14),
const Text(
'مرکز هوش مصنوعی',
style: TextStyle(
color: Colors.green,
fontSize: 15,
fontWeight: FontWeight.bold,
letterSpacing: 0.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 3),
const Text(
'و فناوری‌های نو ظهور',
style: TextStyle(
color: Colors.white,
fontSize: 14,
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
const Text(
'سپاه ثارلله استان کرمان',
style: TextStyle(
color: Colors.red,
fontSize: 14,
letterSpacing: 0.3,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
if (_loading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
color: Color(0xFF00C853),
strokeWidth: 2,
),
)
else
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_isCharging ? Icons.bolt : Icons.battery_std,
color: _isCharging
? const Color(0xFF00C853)
: (_batteryLevel <= 15
? const Color(0xFFFF1744)
: Colors.white24),
size: 10,
),
const SizedBox(width: 4),
Text(
'$_batteryLevel%',
style: TextStyle(
color: _isCharging
? const Color(0xFF00C853)
: (_batteryLevel <= 15
? const Color(0xFFFF1744)
: Colors.white24),
fontSize: 9,
),
),
],
),
],
),
),
),
),
);

View File

@ -20,11 +20,13 @@ class AppNotification {
});
factory AppNotification.fromJson(Map<String, dynamic> json) {
final rawType = (json['type'] as String?) ?? 'PUBLIC';
final normalizedType = _normalizeType(rawType);
return AppNotification(
id: json['id'].toString(),
title: json['title'] as String,
description: json['description'] as String?,
type: (json['type'] as String?) ?? 'PUBLIC',
type: normalizedType,
groupId: json['group_id']?.toString(),
isAccepted: json['is_accepted'] as bool?,
receiverId: json['receiver_id'].toString(),
@ -32,6 +34,17 @@ class AppNotification {
);
}
static String _normalizeType(String rawType) {
final upper = rawType.trim().toUpperCase();
if (upper == 'GROUP_INVITATION' ||
upper == 'GROUP_INVITE' ||
upper == 'INVITE' ||
upper == 'JOIN_REQUEST') {
return 'JOIN_REQUEST';
}
return upper;
}
bool get isJoinRequest => type == 'JOIN_REQUEST';
bool get isPending => isAccepted == null;
}

View File

@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class AboutScreen extends StatefulWidget {
const AboutScreen({super.key});
@override
State<AboutScreen> createState() => _AboutScreenState();
}
class _AboutScreenState extends State<AboutScreen> {
static const _nativeChannel = MethodChannel('com.example.watch/launcher');
// --- متغیرهای مربوط به بخش توضیحات (برای تنظیمات) ---
int _descTapCount = 0;
DateTime? _descLastTapTime;
bool _descIsHolding = false;
// --- متغیرهای مربوط به بخش نسخه (برای EngineerMode) ---
int _verTapCount = 0;
DateTime? _verLastTapTime;
bool _verIsHolding = false;
// تابع باز کردن تنظیمات ساعت
Future<void> _openWatchSettings() async {
try {
await _nativeChannel.invokeMethod('openWatchSettings');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('خطا در باز کردن تنظیمات')),
);
}
}
}
// تابع باز کردن EngineerMode
Future<void> _openEngineerMode() async {
try {
await _nativeChannel.invokeMethod('openEngineerMode');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('خطا در باز کردن مهندسی')));
}
}
}
// --- هندلرهای بخش توضیحات ---
void _handleDescTap() {
if (_descIsHolding) return;
final now = DateTime.now();
if (_descLastTapTime != null &&
now.difference(_descLastTapTime!) > const Duration(seconds: 1)) {
_descTapCount = 0;
}
_descTapCount++;
_descLastTapTime = now;
if (_descTapCount == 5) HapticFeedback.lightImpact();
}
void _handleDescLongPressStart(LongPressStartDetails details) {
setState(() => _descIsHolding = true);
if (_descTapCount >= 5) {
HapticFeedback.heavyImpact();
_openWatchSettings();
} else {
_descTapCount = 0;
}
}
void _handleDescLongPressEnd(LongPressEndDetails details) {
setState(() => _descIsHolding = false);
}
// --- هندلرهای بخش نسخه ---
void _handleVerTap() {
if (_verIsHolding) return;
final now = DateTime.now();
if (_verLastTapTime != null &&
now.difference(_verLastTapTime!) > const Duration(seconds: 1)) {
_verTapCount = 0;
}
_verTapCount++;
_verLastTapTime = now;
if (_verTapCount == 5) HapticFeedback.lightImpact();
}
void _handleVerLongPressStart(LongPressStartDetails details) {
setState(() => _verIsHolding = true);
if (_verTapCount >= 5) {
HapticFeedback.heavyImpact();
_openEngineerMode();
} else {
_verTapCount = 0;
}
}
void _handleVerLongPressEnd(LongPressEndDetails details) {
setState(() => _verIsHolding = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF000000),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// --- لوگو ---
Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Padding(
padding: const EdgeInsets.all(15.0),
child: Image.asset(
'assets/images/logo.png',
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.error_outline,
color: Colors.grey,
size: 50,
);
},
),
),
),
const SizedBox(height: 30),
// --- بخش توضیحات سازنده (قابل کلیک برای تنظیمات) ---
GestureDetector(
onTap: _handleDescTap,
onLongPressStart: _handleDescLongPressStart,
onLongPressEnd: _handleDescLongPressEnd,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
borderRadius: BorderRadius.circular(15),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: const Text(
'توسعه یافته توسط تیم هوش مصنوعی و فناوری های نو ظهور سپاه ثارلله استان کرمان',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.5,
),
),
),
),
const SizedBox(height: 20),
// --- بخش نسخه (قابل کلیک برای EngineerMode) ---
GestureDetector(
onTap: _handleVerTap,
onLongPressStart: _handleVerLongPressStart,
onLongPressEnd: _handleVerLongPressEnd,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.1)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(
Icons.info_outline,
color: Colors.blueAccent,
size: 18,
),
SizedBox(width: 8),
Text(
'نسخه ۱.۰.۰',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
const SizedBox(height: 40),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
),
],
),
),
),
),
);
}
}

View File

@ -1,390 +0,0 @@
import 'package:flutter/material.dart';
import '../models/channel.dart';
import '../services/api_service.dart';
import '../services/auth_service.dart';
import 'channel_screen.dart';
import 'login_screen.dart';
import 'notifications_screen.dart';
class ChannelListScreen extends StatefulWidget {
const ChannelListScreen({super.key});
@override
State<ChannelListScreen> createState() => _ChannelListScreenState();
}
class _ChannelListScreenState extends State<ChannelListScreen> {
final _authService = AuthService();
late final ApiService _api;
List<Channel> _channels = [];
bool _loading = true;
String? _error;
int _pendingNotifCount = 0;
String? _currentUserId;
@override
void initState() {
super.initState();
_api = ApiService(_authService);
_init();
}
Future<void> _init() async {
_currentUserId = await _authService.getUserId();
await _loadChannels();
await _loadNotifCount();
}
Future<void> _loadChannels() async {
setState(() {
_loading = true;
_error = null;
});
final channels = await _api.getChannels();
if (!mounted) return;
if (channels.isEmpty && _channels.isEmpty) {
setState(() {
_loading = false;
_error = 'کانالی یافت نشد';
});
} else {
setState(() {
_channels = channels;
_loading = false;
});
}
}
Future<void> _loadNotifCount() async {
final notifs = await _api.getNotifications();
if (!mounted) return;
setState(() {
_pendingNotifCount = notifs.where((n) => n.isPending).length;
});
}
Future<void> _refresh() async {
await _loadChannels();
await _loadNotifCount();
}
Future<void> _logout() async {
await _authService.logout();
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
void _enterChannel(Channel ch) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChannelScreen(channel: ch, currentUserId: _currentUserId),
),
);
}
void _openNotifications() async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const NotificationsScreen()),
);
// refresh notif count + reload channels after returning (user may have accepted invite)
_refresh();
}
Future<void> _showCreateGroupDialog() async {
String groupName = '';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => _CreateGroupDialog(
onNameChanged: (v) => groupName = v,
),
);
if (confirmed == true && groupName.trim().isNotEmpty) {
final newChannel = await _api.createGroup(groupName.trim());
if (!mounted) return;
if (newChannel != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('گروه ساخته شد', style: TextStyle(fontSize: 11), textAlign: TextAlign.center),
backgroundColor: const Color(0xFF1C1C1E),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
);
_loadChannels();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('خطا در ساخت گروه', style: TextStyle(fontSize: 11), textAlign: TextAlign.center),
backgroundColor: const Color(0xFF333333),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
const SizedBox(width: 4),
const Icon(Icons.settings_input_antenna,
color: Color(0xFF00C853), size: 16),
const SizedBox(width: 4),
const Expanded(
child: Text(
'کانال‌ها',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold),
),
),
// Notifications icon with badge
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: _openNotifications,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
icon: const Icon(Icons.notifications_outlined,
color: Colors.white54, size: 18),
),
if (_pendingNotifCount > 0)
Positioned(
top: 4,
right: 4,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFFFF1744),
shape: BoxShape.circle,
),
),
),
],
),
// Create group
IconButton(
onPressed: _showCreateGroupDialog,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
icon: const Icon(Icons.add_circle_outline,
color: Color(0xFF00C853), size: 18),
),
// Refresh
IconButton(
onPressed: _loading ? null : _refresh,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 32, minHeight: 32),
icon: const Icon(Icons.refresh,
color: Colors.white54, size: 18),
),
// Logout
IconButton(
onPressed: _logout,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 32, minHeight: 32),
icon: const Icon(Icons.logout,
color: Colors.white38, size: 16),
),
],
),
),
// Content
Expanded(
child: _loading
? const Center(
child: CircularProgressIndicator(
color: Color(0xFF00C853), strokeWidth: 2),
)
: _error != null && _channels.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off,
color: Colors.white38, size: 24),
const SizedBox(height: 6),
Text(_error!,
style: const TextStyle(
color: Colors.white38, fontSize: 11)),
const SizedBox(height: 8),
TextButton(
onPressed: _refresh,
child: const Text('تلاش مجدد',
style: TextStyle(
color: Color(0xFF00C853),
fontSize: 11)),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 4),
itemCount: _channels.length,
itemBuilder: (ctx, i) {
return _ChannelTile(
channel: _channels[i],
onTap: () => _enterChannel(_channels[i]),
);
},
),
),
],
),
),
);
}
}
class _ChannelTile extends StatelessWidget {
final Channel channel;
final VoidCallback onTap;
const _ChannelTile({required this.channel, 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(0xFF1C1C1E),
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(
channel.type == 'PUBLIC' ? Icons.radio : Icons.lock_outline,
color: const Color(0xFF00C853),
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
channel.name,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right, color: Colors.white24, size: 16),
],
),
),
);
}
}
// Create Group Dialog
class _CreateGroupDialog extends StatefulWidget {
final ValueChanged<String> onNameChanged;
const _CreateGroupDialog({required this.onNameChanged});
@override
State<_CreateGroupDialog> createState() => _CreateGroupDialogState();
}
class _CreateGroupDialogState extends State<_CreateGroupDialog> {
final _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: const Color(0xFF1C1C1E),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
contentPadding: const EdgeInsets.all(16),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add_circle_outline, color: Color(0xFF00C853), size: 26),
const SizedBox(height: 8),
const Text(
'گروه جدید',
style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
TextField(
controller: _ctrl,
autofocus: true,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 12),
decoration: InputDecoration(
hintText: 'نام گروه',
hintStyle: const TextStyle(color: Colors.white38, fontSize: 11),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: widget.onNameChanged,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('انصراف', style: TextStyle(color: Colors.white54, fontSize: 11)),
),
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('ساخت', style: TextStyle(
color: Color(0xFF00C853),
fontSize: 11,
fontWeight: FontWeight.bold,
)),
),
),
],
),
],
),
);
}
}

View File

@ -0,0 +1,594 @@
// import 'package:flutter/material.dart';
// import 'package:flutter/services.dart';
// import '../models/channel.dart';
// import '../services/api_service.dart';
// import '../services/auth_service.dart';
// import 'channel_screen.dart';
// import 'login_screen.dart';
// import 'notifications_screen.dart';
// import 'wifi_screen.dart';
// class ChannelListScreen extends StatefulWidget {
// const ChannelListScreen({super.key});
// @override
// State<ChannelListScreen> createState() => _ChannelListScreenState();
// }
// class _ChannelListScreenState extends State<ChannelListScreen> {
// static final _nativeChannel = const MethodChannel(
// 'com.example.watch/launcher',
// );
// final _authService = AuthService();
// late final ApiService _api;
// List<Channel> _channels = [];
// bool _loading = true;
// int _pendingNotifCount = 0;
// String? _currentUserId;
// PageController? _pageCtrl;
// int _currentPage = 0;
// bool _initialized = false;
// // Battery
// int _batteryLevel = 0;
// bool _isCharging = false;
// @override
// void initState() {
// super.initState();
// _api = ApiService(_authService);
// _init();
// }
// Future<void> _init() async {
// _currentUserId = await _authService.getUserId();
// await Future.wait([_loadChannels(), _loadNotifCount(), _loadBattery()]);
// }
// Future<void> _loadBattery() async {
// try {
// final info = await _nativeChannel.invokeMapMethod<String, dynamic>(
// 'getBatteryInfo',
// );
// if (!mounted || info == null) return;
// setState(() {
// _batteryLevel = (info['level'] as int?) ?? 0;
// _isCharging = (info['isCharging'] as bool?) ?? false;
// });
// } catch (_) {}
// }
// Future<void> _loadChannels() async {
// setState(() => _loading = true);
// final channels = await _api.getChannels();
// if (!mounted) return;
// setState(() {
// _channels = channels;
// _loading = false;
// if (!_initialized) {
// final startPage = channels.isNotEmpty ? 1 : 0;
// _pageCtrl = PageController(initialPage: startPage);
// _currentPage = startPage;
// _initialized = true;
// }
// });
// }
// Future<void> _loadNotifCount() async {
// final notifs = await _api.getNotifications();
// if (!mounted) return;
// setState(() {
// _pendingNotifCount = notifs.where((n) => n.isPending).length;
// });
// }
// Future<void> _refresh() async {
// await _loadChannels();
// await _loadNotifCount();
// }
// Future<void> _openInternetSettings() async {
// try {
// await _nativeChannel.invokeMethod('openInternetSettings');
// } catch (_) {
// if (!mounted) return;
// _showSnack('تنظیمات اینترنت در دسترس نیست');
// }
// }
// void _openWifi() {
// Navigator.push(
// context,
// MaterialPageRoute(builder: (_) => const WifiScreen()),
// );
// }
// Future<void> _openAccessibilitySettings() async {
// try {
// await _nativeChannel.invokeMethod('openAccessibilitySettings');
// } catch (e) {
// _showSnack('خطا در باز کردن تنظیمات');
// }
// }
// void _showSnack(String msg) {
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: Text(
// msg,
// style: const TextStyle(fontSize: 10, color: Colors.white),
// textAlign: TextAlign.center,
// ),
// backgroundColor: const Color(0xFF2C2C2E),
// behavior: SnackBarBehavior.floating,
// duration: const Duration(seconds: 2),
// margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
// ),
// );
// }
// Future<void> _openHomeSettings() async {
// try {
// await _nativeChannel.invokeMethod('openHomeSettings');
// } catch (_) {
// if (!mounted) return;
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text(
// 'این قابلیت روی دستگاه شما پشتیبانی نمی‌شود',
// style: TextStyle(fontSize: 10, color: Colors.white),
// textAlign: TextAlign.center,
// ),
// backgroundColor: Color(0xFF2C2C2E),
// behavior: SnackBarBehavior.floating,
// duration: Duration(seconds: 2),
// margin: EdgeInsets.symmetric(horizontal: 16, vertical: 40),
// ),
// );
// }
// }
// Future<void> _logout() async {
// await _authService.logout();
// if (!mounted) return;
// Navigator.pushReplacement(
// context,
// MaterialPageRoute(builder: (_) => const LoginScreen()),
// );
// }
// void _enterChannel(Channel ch) {
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (_) =>
// ChannelScreen(channel: ch, currentUserId: _currentUserId),
// ),
// );
// }
// void _openNotifications() async {
// await Navigator.push(
// context,
// MaterialPageRoute(builder: (_) => const NotificationsScreen()),
// );
// _refresh();
// }
// Future<void> _showCreateGroupDialog() async {
// String groupName = '';
// final confirmed = await showDialog<bool>(
// context: context,
// builder: (ctx) => _CreateGroupDialog(onNameChanged: (v) => groupName = v),
// );
// if (confirmed == true && groupName.trim().isNotEmpty) {
// final newChannel = await _api.createGroup(groupName.trim());
// if (!mounted) return;
// ScaffoldMessenger.of(context).showSnackBar(
// SnackBar(
// content: Text(
// newChannel != null ? 'گروه ساخته شد' : 'خطا در ساخت گروه',
// style: const TextStyle(fontSize: 11, color: Colors.white),
// textAlign: TextAlign.center,
// ),
// backgroundColor: const Color(0xFF2C2C2E),
// behavior: SnackBarBehavior.floating,
// duration: const Duration(seconds: 2),
// margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(20),
// ),
// ),
// );
// if (newChannel != null) _loadChannels();
// }
// }
// @override
// void dispose() {
// _pageCtrl?.dispose();
// super.dispose();
// }
// @override
// Widget build(BuildContext context) {
// if (_loading || _pageCtrl == null) {
// return const Scaffold(
// backgroundColor: Colors.black,
// body: Center(
// child: CircularProgressIndicator(
// color: Color(0xFF00C853),
// strokeWidth: 2,
// ),
// ),
// );
// }
// final totalPages = _channels.length + 1; // +1 for menu page
// return Scaffold(
// backgroundColor: Colors.black,
// body: SafeArea(
// child: Column(
// children: [
// Expanded(
// child: PageView.builder(
// controller: _pageCtrl!,
// scrollDirection: Axis.vertical,
// onPageChanged: (i) => setState(() => _currentPage = i),
// itemCount: totalPages,
// itemBuilder: (ctx, i) {
// if (i == 0) return _buildMenuPage();
// return _buildChannelPage(_channels[i - 1]);
// },
// ),
// ),
// _buildPageIndicator(totalPages),
// ],
// ),
// ),
// );
// }
// Widget _buildPageIndicator(int totalPages) {
// return Padding(
// padding: const EdgeInsets.only(bottom: 6, top: 2),
// child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: List.generate(totalPages, (i) {
// final isActive = i == _currentPage;
// if (i == 0) {
// return Padding(
// padding: const EdgeInsets.symmetric(horizontal: 2),
// child: Icon(
// Icons.menu,
// size: isActive ? 9 : 6,
// color: isActive ? Colors.white70 : Colors.white24,
// ),
// );
// }
// return Padding(
// padding: const EdgeInsets.symmetric(horizontal: 2),
// child: Container(
// width: isActive ? 6 : 4,
// height: isActive ? 6 : 4,
// decoration: BoxDecoration(
// color: isActive ? const Color(0xFF00C853) : Colors.white24,
// shape: BoxShape.circle,
// ),
// ),
// );
// }),
// ),
// );
// }
// Widget _buildMenuPage() {
// final batteryColor = _isCharging
// ? const Color(0xFF00C853)
// : _batteryLevel <= 15
// ? const Color(0xFFFF1744)
// : _batteryLevel <= 30
// ? const Color(0xFFFFAB00)
// : Colors.white60;
// // تغییر ListView به SingleChildScrollView و Column
// return Column(
// mainAxisSize: MainAxisSize.min, // ستون فقط به اندازه محتوا فضا میگیرد
// children: [
// // _// Battery display_
// Container(
// height: 32,
// margin: const EdgeInsets.only(bottom: 6),
// padding: const EdgeInsets.symmetric(horizontal: 12),
// decoration: BoxDecoration(
// color: batteryColor.withValues(alpha: 0.1),
// borderRadius: BorderRadius.circular(12),
// border: Border.all(
// color: batteryColor.withValues(alpha: 0.3),
// width: 1,
// ),
// ),
// child: Row(
// children: [
// Icon(
// _isCharging ? Icons.bolt : Icons.battery_std,
// color: batteryColor,
// size: 14,
// ),
// const SizedBox(width: 6),
// Text(
// _isCharging
// ? 'در حال شارژ — $_batteryLevel%'
// : 'باتری: $_batteryLevel%',
// style: TextStyle(color: batteryColor, fontSize: 10),
// ),
// ],
// ),
// ),
// const SizedBox(height: 6),
// _MenuItem(
// icon: Icons.notifications_outlined,
// label: _pendingNotifCount > 0
// ? 'اعلان‌ها ($_pendingNotifCount)'
// : 'اعلان‌ها',
// color: _pendingNotifCount > 0
// ? const Color(0xFFFF1744)
// : Colors.white70,
// onTap: _openNotifications,
// ),
// const SizedBox(height: 6),
// _MenuItem(
// icon: Icons.wifi,
// label: 'وای‌فای',
// color: const Color(0xFF2979FF),
// onTap: _openWifi,
// ),
// const SizedBox(height: 6),
// _MenuItem(
// icon: Icons.cell_tower,
// label: 'اینترنت / سیم‌کارت',
// color: const Color(0xFF00BCD4),
// onTap: _openInternetSettings,
// ),
// const SizedBox(height: 6),
// _MenuItem(
// icon: Icons.accessibility_new,
// label: 'تنظیمات دکمه‌ها',
// color: Colors.orangeAccent,
// onTap: _openAccessibilitySettings,
// ),
// const SizedBox(height: 6),
// _MenuItem(
// icon: Icons.add_circle_outline,
// label: 'گروه جدید',
// color: const Color(0xFF00C853),
// onTap: _showCreateGroupDialog,
// ),
// const SizedBox(height: 6),
// _MenuItem(
// icon: Icons.refresh,
// label: 'بروزرسانی',
// color: Colors.white70,
// onTap: _loading ? null : _refresh,
// ),
// const SizedBox(height: 6),
// _MenuItem(
// icon: Icons.launch,
// label: 'تغییر لانچر',
// color: Colors.white54,
// onTap: _openHomeSettings,
// ),
// const SizedBox(height: 6),
// _MenuItem(
// icon: Icons.logout,
// label: 'خروج',
// color: Colors.redAccent,
// onTap: _logout,
// ),
// ],
// );
// }
// Widget _buildChannelPage(Channel channel) {
// return Center(
// child: GestureDetector(
// onTap: () => _enterChannel(channel),
// child: LayoutBuilder(
// builder: (ctx, constraints) {
// final size = constraints.maxWidth * 0.72;
// return Container(
// width: size,
// height: size,
// decoration: BoxDecoration(
// color: const Color(0xFF1C1C1E),
// shape: BoxShape.circle,
// border: Border.all(
// color: const Color(0xFF00C853).withValues(alpha: 0.55),
// width: 2,
// ),
// boxShadow: [
// BoxShadow(
// color: const Color(0xFF00C853).withValues(alpha: 0.12),
// blurRadius: 22,
// spreadRadius: 2,
// ),
// ],
// ),
// child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Icon(
// channel.type == 'PUBLIC' ? Icons.radio : Icons.lock_outline,
// color: const Color(0xFF00C853),
// size: 22,
// ),
// const SizedBox(height: 8),
// Padding(
// padding: const EdgeInsets.symmetric(horizontal: 20),
// child: Text(
// channel.name,
// style: const TextStyle(
// color: Colors.white,
// fontSize: 13,
// fontWeight: FontWeight.bold,
// ),
// textAlign: TextAlign.center,
// maxLines: 2,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// const SizedBox(height: 4),
// Text(
// channel.type == 'PUBLIC' ? 'عمومی' : 'خصوصی',
// style: const TextStyle(color: Colors.white38, fontSize: 9),
// ),
// ],
// ),
// );
// },
// ),
// ),
// );
// }
// }
// // Menu Item
// class _MenuItem extends StatelessWidget {
// final IconData icon;
// final String label;
// final Color color;
// final VoidCallback? onTap;
// const _MenuItem({
// required this.icon,
// required this.label,
// required this.color,
// this.onTap,
// });
// @override
// Widget build(BuildContext context) {
// return GestureDetector(
// onTap: onTap,
// child: Container(
// height: 38,
// padding: const EdgeInsets.symmetric(horizontal: 12),
// decoration: BoxDecoration(
// color: const Color(0xFF1C1C1E),
// borderRadius: BorderRadius.circular(12),
// ),
// child: Row(
// children: [
// Icon(icon, color: color, size: 16),
// const SizedBox(width: 8),
// Expanded(
// child: Text(label, style: TextStyle(color: color, fontSize: 11)),
// ),
// ],
// ),
// ),
// );
// }
// }
// // Create Group Dialog
// class _CreateGroupDialog extends StatefulWidget {
// final ValueChanged<String> onNameChanged;
// const _CreateGroupDialog({required this.onNameChanged});
// @override
// State<_CreateGroupDialog> createState() => _CreateGroupDialogState();
// }
// class _CreateGroupDialogState extends State<_CreateGroupDialog> {
// final _ctrl = TextEditingController();
// @override
// void dispose() {
// _ctrl.dispose();
// super.dispose();
// }
// @override
// Widget build(BuildContext context) {
// return AlertDialog(
// backgroundColor: const Color(0xFF1C1C1E),
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
// contentPadding: const EdgeInsets.all(16),
// content: Column(
// mainAxisSize: MainAxisSize.min,
// children: [
// const Icon(
// Icons.add_circle_outline,
// color: Color(0xFF00C853),
// size: 26,
// ),
// const SizedBox(height: 8),
// const Text(
// 'گروه جدید',
// style: TextStyle(
// color: Colors.white,
// fontSize: 12,
// fontWeight: FontWeight.bold,
// ),
// ),
// const SizedBox(height: 10),
// TextField(
// controller: _ctrl,
// autofocus: true,
// textAlign: TextAlign.center,
// style: const TextStyle(color: Colors.white, fontSize: 12),
// decoration: InputDecoration(
// hintText: 'نام گروه',
// hintStyle: const TextStyle(color: Colors.white38, fontSize: 11),
// filled: true,
// fillColor: Colors.white.withValues(alpha: 0.05),
// border: OutlineInputBorder(
// borderRadius: BorderRadius.circular(10),
// borderSide: BorderSide.none,
// ),
// contentPadding: const EdgeInsets.symmetric(
// horizontal: 12,
// vertical: 8,
// ),
// ),
// onChanged: widget.onNameChanged,
// ),
// const SizedBox(height: 12),
// Row(
// children: [
// Expanded(
// child: TextButton(
// onPressed: () => Navigator.pop(context, false),
// child: const Text(
// 'انصراف',
// style: TextStyle(color: Colors.white54, fontSize: 11),
// ),
// ),
// ),
// Expanded(
// child: TextButton(
// onPressed: () => Navigator.pop(context, true),
// child: const Text(
// 'ساخت',
// style: TextStyle(
// color: Color(0xFF00C853),
// fontSize: 11,
// fontWeight: FontWeight.bold,
// ),
// ),
// ),
// ),
// ],
// ),
// ],
// ),
// );
// }
// }

View File

@ -49,9 +49,7 @@ class _GroupMembersScreenState extends State<GroupMembersScreen> {
String username = '';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => _InviteDialog(
onUsernameChanged: (v) => username = v,
),
builder: (ctx) => _InviteDialog(onUsernameChanged: (v) => username = v),
);
if (confirmed == true && username.trim().isNotEmpty) {
final err = await _api.inviteMember(widget.channel.id, username.trim());
@ -63,11 +61,15 @@ class _GroupMembersScreenState extends State<GroupMembersScreen> {
style: const TextStyle(fontSize: 11),
textAlign: TextAlign.center,
),
backgroundColor: err == null ? const Color(0xFF1C1C1E) : const Color(0xFF333333),
backgroundColor: err == null
? const Color(0xFF1C1C1E)
: const Color(0xFF333333),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
}
@ -83,11 +85,19 @@ class _GroupMembersScreenState extends State<GroupMembersScreen> {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.person_remove_outlined, color: Colors.red, size: 28),
const Icon(
Icons.person_remove_outlined,
color: Colors.red,
size: 28,
),
const SizedBox(height: 8),
Text(
'حذف ${member.username}؟',
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
@ -96,13 +106,19 @@ class _GroupMembersScreenState extends State<GroupMembersScreen> {
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('انصراف', style: TextStyle(color: Colors.white54, fontSize: 11)),
child: const Text(
'انصراف',
style: TextStyle(color: Colors.white54, fontSize: 11),
),
),
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('حذف', style: TextStyle(color: Colors.red, fontSize: 11)),
child: const Text(
'حذف',
style: TextStyle(color: Colors.red, fontSize: 11),
),
),
),
],
@ -118,12 +134,18 @@ class _GroupMembersScreenState extends State<GroupMembersScreen> {
if (err != null) {
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,
),
backgroundColor: const Color(0xFF333333),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
} else {
@ -136,100 +158,81 @@ class _GroupMembersScreenState extends State<GroupMembersScreen> {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
IconButton(
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white70, size: 14),
// استفاده از Stack برای قرار دادن دکمه شناور روی محتوا
body: Stack(
children: [
SafeArea(
child: Column(
children: [
// هدر ساده شده (فقط نمایش اطلاعات، بدون دکمههای کناری)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
const SizedBox(width: 4),
const Icon(Icons.group_outlined, color: Color(0xFF00C853), size: 14),
const SizedBox(width: 4),
const Expanded(
child: Text(
'اعضا',
style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
),
// Invite button
IconButton(
onPressed: _showInviteDialog,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
icon: const Icon(Icons.person_add_outlined, color: Color(0xFF00C853), size: 16),
),
IconButton(
onPressed: _loading ? null : _load,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
icon: const Icon(Icons.refresh, color: Colors.white54, size: 16),
),
],
),
),
// Group name chip
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF00C853).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.channel.type == 'PUBLIC'
? Icons.public
: Icons.lock_outline,
color: const Color(0xFF00C853),
size: 10,
),
const SizedBox(width: 4),
child: Column(
children: [
// نام گروه و تعداد اعضا وسطچین
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.channel.type == 'PUBLIC'
? Icons.public
: Icons.lock_outline,
color: const Color(0xFF00C853),
size: 12,
),
const SizedBox(width: 4),
Flexible(
child: Text(
widget.channel.name,
style: const TextStyle(
color: Color(0xFF00C853),
fontSize: 12,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
if (!_loading)
Text(
widget.channel.name,
style: const TextStyle(color: Color(0xFF00C853), fontSize: 10),
'${_members.length} عضو',
style: const TextStyle(
color: Colors.white38,
fontSize: 10,
),
),
],
),
],
),
if (!_loading) ...[
const SizedBox(width: 6),
Text(
'${_members.length} عضو',
style: const TextStyle(color: Colors.white38, fontSize: 10),
),
],
],
),
),
const SizedBox(height: 4),
),
const Divider(height: 1, color: Color(0xFF333333)),
// Content
Expanded(
child: _loading
? const Center(
child: CircularProgressIndicator(color: Color(0xFF00C853), strokeWidth: 2),
)
: _members.isEmpty
// لیست اعضا
Expanded(
child: _loading
? const Center(
child: Text('عضوی یافت نشد',
style: TextStyle(color: Colors.white38, fontSize: 11)),
child: CircularProgressIndicator(
color: Color(0xFF00C853),
strokeWidth: 2,
),
)
: _members.isEmpty
? const Center(
child: Text(
'عضوی یافت نشد',
style: TextStyle(
color: Colors.white38,
fontSize: 11,
),
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.only(
bottom: 60,
), // فضای خالی برای دکمه شناور پایین
itemCount: _members.length,
itemBuilder: (ctx, i) {
final m = _members[i];
@ -242,9 +245,32 @@ class _GroupMembersScreenState extends State<GroupMembersScreen> {
);
},
),
),
],
),
],
),
),
// دکمه شناور افزودن عضو (فقط برای مدیر) در پایین صفحه وسط
if (_isManager)
Positioned(
bottom: 10,
left: 0,
right: 0,
child: Center(
child: FloatingActionButton(
heroTag: "invite_btn", // جلوگیری از تداخل HeroTag
mini: true, // سایز کوچکتر مناسب ساعت
onPressed: _showInviteDialog,
backgroundColor: const Color(0xFF00C853),
child: const Icon(
Icons.person_add,
color: Colors.black,
size: 20,
),
),
),
),
],
),
);
}
@ -267,18 +293,18 @@ class _MemberTile extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
height: 40,
height: 36, // کاهش ارتفاع آیتم
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
// Online indicator
Container(
width: 7,
height: 7,
width: 6,
height: 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: member.isOnline ? const Color(0xFF00C853) : Colors.white24,
@ -292,7 +318,9 @@ class _MemberTile extends StatelessWidget {
style: TextStyle(
color: isMe ? const Color(0xFF00C853) : Colors.white,
fontSize: 11,
fontWeight: member.isManager ? FontWeight.bold : FontWeight.normal,
fontWeight: member.isManager
? FontWeight.bold
: FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
@ -300,14 +328,14 @@ class _MemberTile extends StatelessWidget {
// Role badge
if (member.isManager)
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFF00C853).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
color: const Color(0xFF00C853).withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'مدیر',
style: TextStyle(color: Color(0xFF00C853), fontSize: 9),
style: TextStyle(color: Color(0xFF00C853), fontSize: 8),
),
),
// Remove button
@ -315,7 +343,11 @@ class _MemberTile extends StatelessWidget {
const SizedBox(width: 4),
GestureDetector(
onTap: onRemove,
child: const Icon(Icons.remove_circle_outline, color: Colors.red, size: 16),
child: const Icon(
Icons.remove_circle_outline,
color: Colors.red,
size: 16,
),
),
],
],
@ -353,11 +385,19 @@ class _InviteDialogState extends State<_InviteDialog> {
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.person_add_outlined, color: Color(0xFF00C853), size: 26),
const Icon(
Icons.person_add_outlined,
color: Color(0xFF00C853),
size: 26,
),
const SizedBox(height: 8),
const Text(
'دعوت عضو',
style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
TextField(
@ -369,12 +409,15 @@ class _InviteDialogState extends State<_InviteDialog> {
hintText: 'نام کاربری',
hintStyle: const TextStyle(color: Colors.white38, fontSize: 11),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
fillColor: Colors.white.withOpacity(0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
onChanged: widget.onUsernameChanged,
),
@ -384,13 +427,23 @@ class _InviteDialogState extends State<_InviteDialog> {
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('انصراف', style: TextStyle(color: Colors.white54, fontSize: 11)),
child: const Text(
'انصراف',
style: TextStyle(color: Colors.white54, fontSize: 11),
),
),
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('ارسال', style: TextStyle(color: Color(0xFF00C853), fontSize: 11, fontWeight: FontWeight.bold)),
child: const Text(
'ارسال',
style: TextStyle(
color: Color(0xFF00C853),
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
),
],

View File

@ -0,0 +1,247 @@
// import 'dart:async';
// import 'package:flutter/material.dart';
// import 'package:flutter/services.dart';
// import 'channel_list_screen.dart';
// class HomeScreen extends StatefulWidget {
// const HomeScreen({super.key});
// @override
// State<HomeScreen> createState() => _HomeScreenState();
// }
// class _HomeScreenState extends State<HomeScreen> {
// static final _channel = const MethodChannel('com.example.watch/launcher');
// int _batteryLevel = 0;
// bool _isCharging = false;
// Timer? _batteryTimer;
// @override
// void initState() {
// super.initState();
// _loadBattery();
// _batteryTimer = Timer.periodic(
// const Duration(seconds: 30),
// (_) => _loadBattery(),
// );
// }
// Future<void> _loadBattery() async {
// try {
// final info = await _channel.invokeMapMethod<String, dynamic>(
// 'getBatteryInfo',
// );
// if (!mounted || info == null) return;
// setState(() {
// _batteryLevel = (info['level'] as int?) ?? 0;
// _isCharging = (info['isCharging'] as bool?) ?? false;
// });
// } catch (_) {}
// }
// @override
// void dispose() {
// _batteryTimer?.cancel();
// super.dispose();
// }
// Color get _batteryColor {
// if (_isCharging) return const Color(0xFF00C853);
// if (_batteryLevel <= 15) return const Color(0xFFFF1744);
// if (_batteryLevel <= 30) return const Color(0xFFFFAB00);
// return Colors.white54;
// }
// @override
// Widget build(BuildContext context) {
// return Scaffold(
// backgroundColor: Colors.black,
// body: SafeArea(
// child: Stack(
// children: [
// // Battery indicator (top right)
// Positioned(
// top: 4,
// right: 8,
// child: Row(
// mainAxisSize: MainAxisSize.min,
// children: [
// if (_isCharging)
// const Icon(Icons.bolt, color: Color(0xFF00C853), size: 11),
// Text(
// '$_batteryLevel%',
// style: TextStyle(color: _batteryColor, fontSize: 9),
// ),
// ],
// ),
// ),
// // Charging banner
// if (_isCharging)
// Positioned(
// bottom: 10,
// left: 0,
// right: 0,
// child: Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// Container(
// padding: const EdgeInsets.symmetric(
// horizontal: 10,
// vertical: 3,
// ),
// decoration: BoxDecoration(
// color: const Color(0xFF00C853).withValues(alpha: 0.15),
// borderRadius: BorderRadius.circular(20),
// border: Border.all(
// color: const Color(0xFF00C853).withValues(alpha: 0.4),
// width: 1,
// ),
// ),
// child: Row(
// mainAxisSize: MainAxisSize.min,
// children: [
// const Icon(
// Icons.bolt,
// color: Color(0xFF00C853),
// size: 11,
// ),
// const SizedBox(width: 3),
// Text(
// 'در حال شارژ — $_batteryLevel%',
// style: const TextStyle(
// color: Color(0xFF00C853),
// fontSize: 9,
// ),
// ),
// ],
// ),
// ),
// ],
// ),
// ),
// // App grid
// Center(
// child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// const Text(
// 'برنامه‌ها',
// style: TextStyle(
// color: Colors.white38,
// fontSize: 9,
// letterSpacing: 0.5,
// ),
// ),
// const SizedBox(height: 16),
// Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// _AppIcon(
// icon: Icons.radio,
// label: 'بی‌سیم',
// color: const Color(0xFF00C853),
// onTap: () {
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (_) => const ChannelListScreen(),
// ),
// );
// },
// ),
// const SizedBox(width: 20),
// _AppIcon(
// icon: Icons.phone,
// label: 'تلفن',
// color: const Color(0xFF2979FF),
// onTap: () {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(
// content: Text(
// 'به زودی',
// textAlign: TextAlign.center,
// style: TextStyle(
// fontSize: 11,
// color: Colors.white,
// ),
// ),
// duration: Duration(seconds: 1),
// backgroundColor: Color(0xFF2C2C2E),
// behavior: SnackBarBehavior.floating,
// margin: EdgeInsets.symmetric(
// horizontal: 24,
// vertical: 40,
// ),
// ),
// );
// },
// ),
// ],
// ),
// ],
// ),
// ),
// ],
// ),
// ),
// );
// }
// }
// class _AppIcon extends StatelessWidget {
// final IconData icon;
// final String label;
// final Color color;
// final VoidCallback onTap;
// const _AppIcon({
// required this.icon,
// required this.label,
// required this.color,
// required this.onTap,
// });
// @override
// Widget build(BuildContext context) {
// return GestureDetector(
// onTap: onTap,
// child: Column(
// mainAxisSize: MainAxisSize.min,
// children: [
// Container(
// width: 58,
// height: 58,
// decoration: BoxDecoration(
// color: color.withValues(alpha: 0.13),
// shape: BoxShape.circle,
// border: Border.all(
// color: color.withValues(alpha: 0.45),
// width: 1.5,
// ),
// boxShadow: [
// BoxShadow(
// color: color.withValues(alpha: 0.18),
// blurRadius: 12,
// spreadRadius: 1,
// ),
// ],
// ),
// child: Icon(icon, color: color, size: 28),
// ),
// const SizedBox(height: 7),
// Text(
// label,
// style: const TextStyle(
// color: Colors.white,
// fontSize: 11,
// fontWeight: FontWeight.w500,
// ),
// ),
// ],
// ),
// );
// }
// }

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/auth_service.dart';
import '../services/api_service.dart';
import 'channel_list_screen.dart';
import '../home/home_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@ -18,6 +19,11 @@ class _LoginScreenState extends State<LoginScreen> {
bool _loading = false;
String? _error;
// کانال ارتباطی با کد نیتیو برای تنظیمات اینترنت
static final _nativeChannel = const MethodChannel(
'com.example.watch/launcher',
);
@override
void initState() {
super.initState();
@ -31,6 +37,31 @@ class _LoginScreenState extends State<LoginScreen> {
super.dispose();
}
// متد باز کردن تنظیمات اینترنت
Future<void> _openInternetSettings() async {
try {
await _nativeChannel.invokeMethod('openInternetSettings');
} catch (_) {
_showSnack('تنظیمات اینترنت در دسترس نیست');
}
}
// متد نمایش پیام (SnackBar)
void _showSnack(String msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
msg,
style: const TextStyle(fontSize: 10, color: Colors.white),
),
backgroundColor: const Color(0xFF2C2C2E),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
),
);
}
Future<void> _login() async {
final username = _usernameCtrl.text.trim();
final secret = _secretCtrl.text.trim();
@ -49,7 +80,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (ok) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ChannelListScreen()),
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
} else {
setState(() {
@ -67,150 +98,203 @@ class _LoginScreenState extends State<LoginScreen> {
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: MediaQuery.of(context).size.height -
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,
),
),
const SizedBox(height: 4),
const Text(
'WalkieTalkie',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
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,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// محتوای اصلی فرم
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Icon
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _loading
? const Color(0xFF424242)
: const Color(0xFF00C853),
color: const Color(0xFF00C853).withOpacity(0.15),
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,
),
child: const Icon(
Icons.settings_input_antenna,
color: Color(0xFF00C853),
size: 18,
),
),
const SizedBox(height: 4),
const Text(
'WalkieTalkie',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 1,
),
),
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,
),
)
: const Icon(Icons.login, color: Colors.white, size: 22),
: 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,
).withOpacity(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: 20),
// دکمه تنظیمات اینترنت (پایین صفحه وسط)
GestureDetector(
onTap: _openInternetSettings,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF00BCD4).withOpacity(0.3),
width: 1,
),
),
const SizedBox(height: 4),
const Text(
'ورود',
style: TextStyle(color: Colors.white38, fontSize: 9),
child: const Icon(
Icons.wifi,
color: Color(0xFF00BCD4),
size: 20,
),
],
),
),
),
],
),
),
),

View File

@ -42,12 +42,18 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
if (err != null) {
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,
),
backgroundColor: const Color(0xFF333333),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
);
} else {
@ -63,64 +69,76 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
body: SafeArea(
child: Column(
children: [
// Header
// Header ساده و وسطچین (بدون دکمههای کناری)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white70, size: 14),
const Icon(
Icons.notifications_outlined,
color: Color(0xFF00C853),
size: 16,
),
const SizedBox(width: 4),
const Icon(Icons.notifications_outlined, color: Color(0xFF00C853), size: 14),
const SizedBox(width: 4),
const Expanded(
child: Text(
'اعلان‌ها',
style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
const SizedBox(width: 6),
const Text(
'اعلان‌ها',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: _loading ? null : _load,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
icon: const Icon(Icons.refresh, color: Colors.white54, size: 16),
),
],
),
),
const Divider(height: 1, color: Color(0xFF333333)),
// Content
Expanded(
child: _loading
? const Center(
child: CircularProgressIndicator(color: Color(0xFF00C853), strokeWidth: 2),
child: CircularProgressIndicator(
color: Color(0xFF00C853),
strokeWidth: 2,
),
)
: _notifications.isEmpty
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_off_outlined, color: Colors.white24, size: 28),
SizedBox(height: 6),
Text('اعلانی وجود ندارد',
style: TextStyle(color: Colors.white38, fontSize: 11)),
],
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.notifications_off_outlined,
color: Colors.white24,
size: 32,
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 4),
itemCount: _notifications.length,
itemBuilder: (ctx, i) => _NotifTile(
notif: _notifications[i],
isProcessing: _processing.contains(_notifications[i].id),
onAccept: () => _respond(_notifications[i], true),
onReject: () => _respond(_notifications[i], false),
SizedBox(height: 8),
Text(
'اعلانی وجود ندارد',
style: TextStyle(
color: Colors.white38,
fontSize: 12,
),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
itemCount: _notifications.length,
itemBuilder: (ctx, i) => _NotifTile(
notif: _notifications[i],
isProcessing: _processing.contains(
_notifications[i].id,
),
onAccept: () => _respond(_notifications[i], true),
onReject: () => _respond(_notifications[i], false),
),
),
),
],
),
@ -157,16 +175,16 @@ class _NotifTile extends StatelessWidget {
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
margin: const EdgeInsets.only(bottom: 8), // فاصله عمودی بین آیتمها
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isPending ? statusColor.withValues(alpha: 0.4) : Colors.transparent,
color: isPending ? statusColor.withOpacity(0.4) : Colors.transparent,
width: 1,
),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -175,15 +193,15 @@ class _NotifTile extends StatelessWidget {
Icon(
isJoin ? Icons.group_add_outlined : Icons.campaign_outlined,
color: statusColor,
size: 13,
size: 14,
),
const SizedBox(width: 5),
const SizedBox(width: 6),
Expanded(
child: Text(
notif.title,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontSize: 12,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
@ -195,37 +213,47 @@ class _NotifTile extends StatelessWidget {
const SizedBox(height: 4),
Text(
notif.description!,
style: const TextStyle(color: Colors.white60, fontSize: 10),
maxLines: 2,
style: const TextStyle(color: Colors.white70, fontSize: 11),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
if (isJoin && isPending) ...[
const SizedBox(height: 6),
const SizedBox(height: 8),
if (isProcessing)
const Center(
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(color: Color(0xFF00C853), strokeWidth: 1.5),
width: 16,
height: 16,
child: CircularProgressIndicator(
color: Color(0xFF00C853),
strokeWidth: 2,
),
),
)
else
Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end, // دکمهها سمت راست
children: [
// Reject
GestureDetector(
onTap: onReject,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.15),
color: Colors.red.withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'رد',
style: TextStyle(color: Colors.red, fontSize: 10, fontWeight: FontWeight.bold),
style: TextStyle(
color: Colors.red,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
),
@ -234,14 +262,21 @@ class _NotifTile extends StatelessWidget {
GestureDetector(
onTap: onAccept,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0xFF00C853).withValues(alpha: 0.15),
color: const Color(0xFF00C853).withOpacity(0.15),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'قبول',
style: TextStyle(color: Color(0xFF00C853), fontSize: 10, fontWeight: FontWeight.bold),
style: TextStyle(
color: Color(0xFF00C853),
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
),
@ -251,10 +286,10 @@ class _NotifTile extends StatelessWidget {
if (!isPending) ...[
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
alignment: Alignment.centerRight,
child: Text(
notif.isAccepted == true ? 'پذیرفته شد' : 'رد شد',
style: TextStyle(color: statusColor, fontSize: 9),
style: TextStyle(color: statusColor, fontSize: 10),
),
),
],

View File

@ -0,0 +1,334 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class WifiScreen extends StatefulWidget {
const WifiScreen({super.key});
@override
State<WifiScreen> createState() => _WifiScreenState();
}
class _WifiScreenState extends State<WifiScreen> {
static final _channel = const MethodChannel('com.example.watch/launcher');
List<Map<String, dynamic>> _networks = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_scan();
}
Future<void> _scan() async {
setState(() {
_loading = true;
_error = null;
});
try {
final raw = await _channel.invokeListMethod<Object>('getWifiList');
if (!mounted) return;
setState(() {
_networks = (raw ?? [])
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
_loading = false;
if (_networks.isEmpty) _error = 'شبکه‌ای یافت نشد';
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = 'خطا در اسکن وای‌فای';
});
}
}
Future<void> _openWifiSettings() async {
try {
await _channel.invokeMethod('openWifiSettings');
} catch (_) {}
}
Future<void> _connectOrSuggest(String ssid, String password) async {
try {
final ok = await _channel.invokeMethod<bool>(
'connectToWifi',
{'ssid': ssid, 'password': password},
);
if (!mounted) return;
if (ok == true) {
_showSnack('درخواست اتصال به "$ssid" ارسال شد');
} else {
_openWifiSettings();
}
} catch (_) {
_openWifiSettings();
}
}
void _showSnack(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(msg,
style: const TextStyle(fontSize: 10, color: Colors.white),
textAlign: TextAlign.center),
backgroundColor: const Color(0xFF2C2C2E),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
));
}
int _bars(int level) {
if (level >= -50) return 3;
if (level >= -70) return 2;
return 1;
}
IconData _wifiIcon(int bars) {
if (bars >= 3) return Icons.wifi;
if (bars == 2) return Icons.wifi_2_bar;
return Icons.wifi_1_bar;
}
void _onNetworkTap(String ssid, bool secured) {
if (secured) {
_showPasswordDialog(ssid);
} else {
_connectOrSuggest(ssid, '');
}
}
void _showPasswordDialog(String ssid) async {
String password = '';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1C1C1E),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
contentPadding: const EdgeInsets.all(14),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_password, color: Color(0xFF00C853), size: 22),
const SizedBox(height: 6),
Text(ssid,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold),
textAlign: TextAlign.center),
const SizedBox(height: 10),
TextField(
autofocus: true,
obscureText: true,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 11),
decoration: InputDecoration(
hintText: 'رمز عبور',
hintStyle:
const TextStyle(color: Colors.white38, fontSize: 10),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: (v) => password = v,
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('انصراف',
style:
TextStyle(color: Colors.white54, fontSize: 11)),
),
),
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('اتصال',
style: TextStyle(
color: Color(0xFF00C853),
fontSize: 11,
fontWeight: FontWeight.bold)),
),
),
],
),
],
),
),
);
if (confirmed == true && password.isNotEmpty) {
_connectOrSuggest(ssid, password);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: [
// Header
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(Icons.arrow_back_ios_new,
color: Colors.white54, size: 14),
),
const SizedBox(width: 6),
const Icon(Icons.wifi, color: Color(0xFF00C853), size: 14),
const SizedBox(width: 6),
const Text('وای‌فای',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
GestureDetector(
onTap: _loading ? null : _scan,
child: Icon(Icons.refresh,
color: _loading ? Colors.white24 : Colors.white54,
size: 14),
),
],
),
),
// Content
Expanded(
child: _loading
? const Center(
child: CircularProgressIndicator(
color: Color(0xFF00C853), strokeWidth: 2))
: _error != null && _networks.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off,
color: Colors.white38, size: 24),
const SizedBox(height: 6),
Text(_error!,
style: const TextStyle(
color: Colors.white38, fontSize: 11)),
const SizedBox(height: 10),
GestureDetector(
onTap: _openWifiSettings,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
borderRadius: BorderRadius.circular(12),
),
child: const Text('تنظیمات وای‌فای',
style: TextStyle(
color: Color(0xFF00C853),
fontSize: 10)),
),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 4),
itemCount: _networks.length + 1,
itemBuilder: (ctx, i) {
// Last item: settings link
if (i == _networks.length) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
child: GestureDetector(
onTap: _openWifiSettings,
child: Container(
height: 34,
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
borderRadius:
BorderRadius.circular(12),
),
child: const Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.settings,
size: 12,
color: Colors.white38),
SizedBox(width: 4),
Text('تنظیمات وای‌فای',
style: TextStyle(
color: Colors.white38,
fontSize: 10)),
],
),
),
),
);
}
final net = _networks[i];
final ssid = (net['ssid'] as String?) ?? '';
final level = (net['level'] as int?) ?? -100;
final secured =
(net['secured'] as bool?) ?? false;
final bars = _bars(level);
return GestureDetector(
onTap: () => _onNetworkTap(ssid, secured),
child: Container(
margin: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF1C1C1E),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 10),
child: Row(
children: [
Icon(_wifiIcon(bars),
color: const Color(0xFF00C853),
size: 14),
const SizedBox(width: 8),
Expanded(
child: Text(
ssid,
style: const TextStyle(
color: Colors.white,
fontSize: 11),
overflow: TextOverflow.ellipsis,
),
),
if (secured)
const Icon(Icons.lock_outline,
color: Colors.white38, size: 10),
],
),
),
);
},
),
),
],
),
),
);
}
}

View File

@ -128,7 +128,10 @@ class ApiService {
Future<Channel?> createGroup(String name) async {
if (AppConfig.debug) {
await Future.delayed(const Duration(milliseconds: 400));
return Channel(id: 'fake_${DateTime.now().millisecondsSinceEpoch}', name: name);
return Channel(
id: 'fake_${DateTime.now().millisecondsSinceEpoch}',
name: name,
);
}
try {
@ -154,9 +157,24 @@ class ApiService {
if (AppConfig.debug) {
await Future.delayed(const Duration(milliseconds: 400));
return [
const GroupMember(userId: 'u1', username: 'علی', role: 'MANAGER', isOnline: true),
const GroupMember(userId: 'u2', username: 'رضا', role: 'MEMBER', isOnline: false),
const GroupMember(userId: 'u3', username: 'مریم', role: 'MEMBER', isOnline: true),
const GroupMember(
userId: 'u1',
username: 'علی',
role: 'MANAGER',
isOnline: true,
),
const GroupMember(
userId: 'u2',
username: 'رضا',
role: 'MEMBER',
isOnline: false,
),
const GroupMember(
userId: 'u3',
username: 'مریم',
role: 'MEMBER',
isOnline: true,
),
];
}
@ -273,6 +291,7 @@ class ApiService {
if (res.statusCode == 200) {
final list = jsonDecode(res.body) as List;
print(list);
return list
.map((e) => AppNotification.fromJson(e as Map<String, dynamic>))
.toList();
@ -283,7 +302,10 @@ class ApiService {
}
}
Future<String?> respondToNotification(String notificationId, bool isAccepted) async {
Future<String?> respondToNotification(
String notificationId,
bool isAccepted,
) async {
if (AppConfig.debug) {
await Future.delayed(const Duration(milliseconds: 400));
return null;
@ -292,7 +314,9 @@ class ApiService {
try {
final res = await http
.post(
Uri.parse('${AppConfig.baseUrl}/notifications/$notificationId/respond'),
Uri.parse(
'${AppConfig.baseUrl}/notifications/$notificationId/respond',
),
headers: await _headers(),
body: jsonEncode({'is_accepted': isAccepted}),
)

View File

@ -73,8 +73,7 @@ class PttService {
_livekitUrl = livekitUrl;
try {
final wsUrl =
'${AppConfig.wsBaseUrl}/ws/groups/$groupId?token=$jwtToken';
final wsUrl = '${AppConfig.wsBaseUrl}/ws/groups/$groupId?token=$jwtToken';
_ws = await WebSocket.connect(wsUrl);
_listenerTokenCompleter = Completer<String?>();
@ -135,13 +134,15 @@ class PttService {
case 'speaker_granted':
// درخواست صحبت تایید شد
if (_speakGrantCompleter != null && !_speakGrantCompleter!.isCompleted) {
if (_speakGrantCompleter != null &&
!_speakGrantCompleter!.isCompleted) {
_speakGrantCompleter!.complete(msg['token'] as String?);
}
case 'speaker_busy':
// خط اشغاله
if (_speakGrantCompleter != null && !_speakGrantCompleter!.isCompleted) {
if (_speakGrantCompleter != null &&
!_speakGrantCompleter!.isCompleted) {
_speakGrantCompleter!.complete(null);
}
_errorCtrl.add('خط اشغال است');
@ -172,21 +173,23 @@ class PttService {
bool setConnectedState = true,
}) async {
try {
await _listener?.dispose();
await _room?.disconnect();
try {
await _listener?.dispose();
} catch (_) {}
try {
await _room?.disconnect().timeout(
const Duration(seconds: 2),
onTimeout: () => null,
);
} catch (_) {}
_room = Room(
roomOptions: const RoomOptions(
adaptiveStream: false,
dynacast: false,
),
roomOptions: const RoomOptions(adaptiveStream: false, dynacast: false),
);
_listener = _room!.createListener();
_listener!
..on<ActiveSpeakersChangedEvent>(
(e) => _onSpeakersChanged(e.speakers),
)
..on<ActiveSpeakersChangedEvent>((e) => _onSpeakersChanged(e.speakers))
..on<RoomDisconnectedEvent>((_) {
if (_state != PttState.speaking) _setState(PttState.idle);
});
@ -301,12 +304,29 @@ class PttService {
await _room?.localParticipant?.setMicrophoneEnabled(false);
} catch (_) {}
await _listener?.dispose();
await _room?.disconnect();
_room = null;
_listener = null;
try {
await _listener?.dispose();
} catch (_) {}
await _ws?.close();
try {
if (_room != null) {
// LiveKit's disconnect can sometimes timeout or throw,
// we wrap it to ensure cleanup proceeds.
await _room!.disconnect().timeout(
const Duration(seconds: 3),
onTimeout: () => null,
);
}
} catch (e) {
// Ignore disconnect errors as we are shutting down
} finally {
_room = null;
_listener = null;
}
try {
await _ws?.close();
} catch (_) {}
_ws = null;
_setState(PttState.idle);

View File

@ -24,3 +24,5 @@ dev_dependencies:
flutter:
uses-material-design: true
assets:
- assets/images/

41
Security_disable.txt Normal file
View File

@ -0,0 +1,41 @@
#غیرفعال کردن لانچر اصلی
adb shell pm disable-user com.dw.launcher
#غیرفعال کردن ui سیستمی و توپ کویک
adb shell pm disable-user plugin.sprd.systemuidynanavigationbar
adb shell pm disable-user com.android.systemui
adb shell reboot
# غیرفعال کردن فروشگاه‌ها و سرویس‌های گوگل (جلوگیری از دانلود برنامه ناخواسته)
adb shell pm disable-user com.android.vending
adb shell pm disable-user com.google.android.gms
adb shell pm disable-user com.google.android.gsf
# غیرفعال کردن برنامه‌های پرخطر (مرورگر، مدیریت فایل، انتقال فایل)
adb shell pm disable-user com.android.browser
adb shell pm disable-user com.sprd.fileexplorer
adb shell pm disable-user com.lenovo.anyshare.gps
# غیرفعال کردن برنامه‌های چندرسانه‌ای و سرگرمی
adb shell pm disable-user com.google.android.youtube
adb shell pm disable-user com.android.gallery3d
adb shell pm disable-user com.android.musicfx
adb shell pm disable-user com.dw.music
adb shell pm disable-user com.dw.calendar
adb shell pm disable-user com.dw.calculator
adb shell pm disable-user com.dw.timer
adb shell pm disable-user com.dw.stopwatch
adb shell pm disable-user com.dw.deskclock
# غیرفعال کردن برنامه‌های بی‌سیم و بلوتوث اضافی
adb shell pm disable-user com.sprd.wirelesstools
adb shell pm disable-user com.sprd.firewall
#قطع کامل دوربین
adb shell pm disable-user com.android.camera2
adb shell settings put secure camera_disabled 1
#غیرفعال کردن gps
adb shell pm disable-user com.android.location.fused
adb shell settings put secure location_providers_allowed -gps
adb shell settings put secure location_providers_allowed -network

View File

@ -4,10 +4,10 @@ class AppConfig {
// Debug Toggle
/// When true, the app uses in-memory mock data (no network calls).
/// When false, the app communicates with the real backend API.
static const bool debugMode = true;
static const bool debugMode = false;
// API Settings
static const String baseUrl = 'http://localhost:8000';
static const String baseUrl = 'http://192.168.3.9:8000';
// App Metadata
static const String appName = 'NEDA Admin';

View File

@ -4,6 +4,7 @@ import 'config/app_config.dart';
import 'providers/auth_provider.dart';
import 'providers/user_provider.dart';
import 'providers/group_provider.dart';
import 'providers/notification_provider.dart';
import 'router/app_router.dart';
import 'services/service_locator.dart';
import 'theme/app_theme.dart';
@ -23,6 +24,7 @@ class NedaAdminApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => UserProvider()),
ChangeNotifierProvider(create: (_) => GroupProvider()),
ChangeNotifierProvider(create: (_) => NotificationProvider()),
],
child: MaterialApp.router(
title: AppConfig.appName,

View File

@ -4,9 +4,9 @@ extension GroupRoleExtension on GroupRole {
String get label {
switch (this) {
case GroupRole.manager:
return 'Manager';
return 'مدیر';
case GroupRole.member:
return 'Member';
return 'عضو';
}
}
@ -15,56 +15,48 @@ extension GroupRoleExtension on GroupRole {
class GroupMemberModel {
final String userId;
final String groupId;
final GroupRole role;
/// Denormalized username for display populated from local user cache
final String? username;
final DateTime? joinedAt;
final bool isOnline;
const GroupMemberModel({
required this.userId,
required this.groupId,
required this.role,
this.username,
this.joinedAt,
this.isOnline = false,
});
factory GroupMemberModel.fromJson(Map<String, dynamic> json) {
return GroupMemberModel(
userId: json['user_id'] as String,
groupId: json['group_id'] as String,
userId: json['user_id'].toString(),
role: GroupRole.values.firstWhere(
(r) => r.name == (json['role'] as String),
orElse: () => GroupRole.member,
),
username: json['username'] as String?,
joinedAt: json['joined_at'] != null
? DateTime.tryParse(json['joined_at'] as String)
: null,
isOnline: (json['is_online'] as bool?) ?? false,
);
}
Map<String, dynamic> toJson() => {
'user_id': userId,
'group_id': groupId,
'role': role.apiValue,
if (username != null) 'username': username,
if (joinedAt != null) 'joined_at': joinedAt!.toIso8601String(),
'is_online': isOnline,
};
GroupMemberModel copyWith({
String? userId,
String? groupId,
GroupRole? role,
String? username,
DateTime? joinedAt,
bool? isOnline,
}) {
return GroupMemberModel(
userId: userId ?? this.userId,
groupId: groupId ?? this.groupId,
role: role ?? this.role,
username: username ?? this.username,
joinedAt: joinedAt ?? this.joinedAt,
isOnline: isOnline ?? this.isOnline,
);
}
}

View File

@ -1,68 +1,59 @@
enum GroupType { group, direct }
enum GroupType { public, private }
extension GroupTypeExtension on GroupType {
String get label {
switch (this) {
case GroupType.public:
return 'عمومی';
case GroupType.private:
return 'خصوصی';
}
}
}
class GroupModel {
final String id;
final String name;
final String? description;
final bool isActive;
final GroupType type;
final DateTime? createdAt;
final int memberCount;
const GroupModel({
required this.id,
required this.name,
this.description,
required this.isActive,
this.type = GroupType.group,
this.createdAt,
this.memberCount = 0,
this.type = GroupType.public,
});
factory GroupModel.fromJson(Map<String, dynamic> json) {
return GroupModel(
id: json['id'] as String,
id: json['id'].toString(),
name: json['name'] as String,
description: json['description'] as String?,
isActive: (json['is_active'] as bool?) ?? true,
type: GroupType.values.firstWhere(
(t) => t.name == (json['type'] as String? ?? 'group'),
orElse: () => GroupType.group,
(t) => t.name == (json['type'] as String? ?? 'public'),
orElse: () => GroupType.public,
),
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String)
: null,
memberCount: (json['member_count'] as int?) ?? 0,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (description != null) 'description': description,
'is_active': isActive,
'type': type.name,
if (createdAt != null) 'created_at': createdAt!.toIso8601String(),
'member_count': memberCount,
};
GroupModel copyWith({
String? id,
String? name,
String? description,
bool? isActive,
GroupType? type,
DateTime? createdAt,
int? memberCount,
}) {
return GroupModel(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
isActive: isActive ?? this.isActive,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
memberCount: memberCount ?? this.memberCount,
);
}
}

View File

@ -0,0 +1,78 @@
enum NotificationType { public, joinRequest }
extension NotificationTypeExtension on NotificationType {
String get label {
switch (this) {
case NotificationType.public:
return 'عمومی';
case NotificationType.joinRequest:
return 'درخواست عضویت';
}
}
String get apiValue {
switch (this) {
case NotificationType.public:
return 'public';
case NotificationType.joinRequest:
return 'join_request';
}
}
static NotificationType fromApi(String value) {
switch (value) {
case 'public':
return NotificationType.public;
case 'join_request':
return NotificationType.joinRequest;
default:
return NotificationType.public;
}
}
}
class NotificationModel {
final String id;
final String title;
final String? description;
final NotificationType type;
final bool? isAccepted;
final String receiverId;
final String? senderId;
final String? groupId;
const NotificationModel({
required this.id,
required this.title,
this.description,
required this.type,
this.isAccepted,
required this.receiverId,
this.senderId,
this.groupId,
});
factory NotificationModel.fromJson(Map<String, dynamic> json) {
return NotificationModel(
id: json['id'].toString(),
title: json['title'] as String,
description: json['description'] as String?,
type: NotificationTypeExtension.fromApi(json['type'] as String),
isAccepted: json['is_accepted'] as bool?,
receiverId: json['receiver_id'].toString(),
senderId: json['sender_id']?.toString(),
groupId: json['group_id']?.toString(),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'type': type.apiValue,
'is_accepted': isAccepted,
'receiver_id': receiverId,
'sender_id': senderId,
'group_id': groupId,
};
}

View File

@ -1,14 +1,12 @@
enum UserRole { admin, group_manager, member }
enum UserRole { admin, member }
extension UserRoleExtension on UserRole {
String get label {
switch (this) {
case UserRole.admin:
return 'Admin';
case UserRole.group_manager:
return 'Group Manager';
return 'ادمین';
case UserRole.member:
return 'Member';
return 'کاربر';
}
}
@ -18,15 +16,18 @@ extension UserRoleExtension on UserRole {
class UserModel {
final String id;
final String username;
final String? phoneNumber;
final UserRole role;
final bool isActive;
final DateTime? createdAt;
/// Only available immediately after creation or secret reset
final String? secret;
const UserModel({
required this.id,
required this.username,
this.phoneNumber,
required this.role,
required this.isActive,
this.createdAt,
@ -34,13 +35,16 @@ class UserModel {
});
factory UserModel.fromJson(Map<String, dynamic> json) {
// Backend only exposes is_admin (no role field)
UserRole mappedRole = json['is_admin'] == true
? UserRole.admin
: UserRole.member;
return UserModel(
id: json['id'] as String,
id: json['id'].toString(),
username: json['username'] as String,
role: UserRole.values.firstWhere(
(r) => r.name == (json['role'] as String),
orElse: () => UserRole.member,
),
phoneNumber: json['phone_number'] as String?,
role: mappedRole,
isActive: (json['is_active'] as bool?) ?? true,
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String)
@ -51,7 +55,8 @@ class UserModel {
Map<String, dynamic> toJson() => {
'id': id,
'username': username,
'role': role.apiValue,
'phone_number': phoneNumber,
'is_admin': role == UserRole.admin,
'is_active': isActive,
if (createdAt != null) 'created_at': createdAt!.toIso8601String(),
};
@ -59,6 +64,7 @@ class UserModel {
UserModel copyWith({
String? id,
String? username,
String? phoneNumber,
UserRole? role,
bool? isActive,
DateTime? createdAt,
@ -67,6 +73,7 @@ class UserModel {
return UserModel(
id: id ?? this.id,
username: username ?? this.username,
phoneNumber: phoneNumber ?? this.phoneNumber,
role: role ?? this.role,
isActive: isActive ?? this.isActive,
createdAt: createdAt ?? this.createdAt,

View File

@ -39,10 +39,10 @@ class GroupProvider extends ChangeNotifier {
notifyListeners();
}
Future<GroupModel?> createGroup(String name, String? description) async {
Future<GroupModel?> createGroup(String name) async {
_error = null;
try {
final group = await ServiceLocator().groups.createGroup(name, description);
final group = await ServiceLocator().groups.createGroup(name);
_groups = [..._groups, group];
_membersCache[group.id] = [];
notifyListeners();
@ -58,36 +58,40 @@ class GroupProvider extends ChangeNotifier {
}
}
Future<GroupMemberModel?> addMember(
String groupId,
String userId,
GroupRole role,
String? username,
) async {
Future<bool> inviteMember(String groupId, String username) async {
_error = null;
try {
final member =
await ServiceLocator().groups.addMember(groupId, userId, role);
final withUsername = member.copyWith(username: username);
_membersCache.putIfAbsent(groupId, () => []).add(withUsername);
// Refresh member count on cached group
final idx = _groups.indexWhere((g) => g.id == groupId);
if (idx != -1) {
_groups[idx] = _groups[idx].copyWith(
memberCount: _membersCache[groupId]!.length,
);
}
notifyListeners();
return withUsername;
await ServiceLocator().groups.inviteMember(groupId, username);
// We don't necessarily add to cache here because it's an invitation.
// But we can reload members to be sure.
await loadGroupMembers(groupId);
return true;
} on ApiException catch (e) {
_error = e.message;
notifyListeners();
return null;
return false;
} catch (e) {
_error = 'خطا در افزودن عضو';
_error = 'خطا در دعوت عضو';
notifyListeners();
return null;
return false;
}
}
Future<bool> removeMember(String groupId, String userId) async {
_error = null;
try {
await ServiceLocator().groups.removeMember(groupId, userId);
_membersCache[groupId]?.removeWhere((m) => m.userId == userId);
notifyListeners();
return true;
} on ApiException catch (e) {
_error = e.message;
notifyListeners();
return false;
} catch (e) {
_error = 'خطا در حذف عضو';
notifyListeners();
return false;
}
}

View File

@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import '../models/notification_model.dart';
import '../services/service_locator.dart';
import '../services/api/api_client.dart';
enum NotificationLoadStatus { idle, loading, success, error }
class NotificationProvider extends ChangeNotifier {
List<NotificationModel> _items = [];
NotificationLoadStatus _status = NotificationLoadStatus.idle;
String? _error;
List<NotificationModel> get items => List.unmodifiable(_items);
NotificationLoadStatus get status => _status;
String? get error => _error;
bool get isLoading => _status == NotificationLoadStatus.loading;
Future<void> loadNotifications() async {
_status = NotificationLoadStatus.loading;
_error = null;
notifyListeners();
try {
_items = await ServiceLocator().notifications.getNotifications();
_status = NotificationLoadStatus.success;
} on ApiException catch (e) {
_error = e.message;
_status = NotificationLoadStatus.error;
} catch (e) {
_error = 'خطا در دریافت اعلان‌ها';
_status = NotificationLoadStatus.error;
}
notifyListeners();
}
Future<bool> sendPublic(String title, String description) async {
_error = null;
try {
await ServiceLocator().notifications.sendPublicNotification(
title,
description,
);
await loadNotifications();
return true;
} on ApiException catch (e) {
_error = e.message;
notifyListeners();
return false;
} catch (_) {
_error = 'خطا در ارسال اعلان';
notifyListeners();
return false;
}
}
}

View File

@ -38,12 +38,15 @@ class UserProvider extends ChangeNotifier {
/// Returns the new user and its generated secret.
Future<({UserModel user, String secret})?> createUser(
String username,
UserRole role,
) async {
String username, {
String? phoneNumber,
}) async {
_error = null;
try {
final result = await ServiceLocator().users.createUser(username, role);
final result = await ServiceLocator().users.createUser(
username,
phoneNumber: phoneNumber,
);
_users = [..._users, result.user];
notifyListeners();
return result;
@ -75,6 +78,22 @@ class UserProvider extends ChangeNotifier {
}
}
Future<bool> logoutUser(String userId) async {
_error = null;
try {
await ServiceLocator().users.logoutUser(userId);
return true;
} on ApiException catch (e) {
_error = e.message;
notifyListeners();
return false;
} catch (e) {
_error = 'خطا در خروج کاربر';
notifyListeners();
return false;
}
}
void clearError() {
_error = null;
notifyListeners();

View File

@ -7,6 +7,7 @@ import '../screens/dashboard_screen.dart';
import '../screens/users_screen.dart';
import '../screens/groups_screen.dart';
import '../screens/group_detail_screen.dart';
import '../screens/notifications_screen.dart';
final appRouter = GoRouter(
initialLocation: '/login',
@ -34,6 +35,10 @@ final appRouter = GoRouter(
groupId: state.pathParameters['id']!,
),
),
GoRoute(
path: '/notifications',
builder: (_, __) => const NotificationsScreen(),
),
],
);

View File

@ -72,8 +72,8 @@ class _DashboardBody extends StatelessWidget {
),
if (AppConfig.debugMode)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.warning.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
@ -116,35 +116,6 @@ class _DashboardBody extends StatelessWidget {
),
const SizedBox(height: 16),
_QuickActions(),
const SizedBox(height: 28),
// Info banner (real API mode)
if (!AppConfig.debugMode)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.primary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.primary.withValues(alpha: 0.2)),
),
child: Row(
children: [
const Icon(Icons.info_outline_rounded,
color: AppTheme.primary, size: 20),
const SizedBox(width: 12),
const Expanded(
child: Text(
'در حالت واقعی، لیست کاربران و گروه‌ها فقط شامل آیتم‌های ایجادشده در همین نشست می‌باشد. بک‌اند فاقد endpoint لیست است.',
style: TextStyle(
fontSize: 13,
color: AppTheme.primary,
),
),
),
],
),
),
],
),
);
@ -239,12 +210,20 @@ class _QuickActions extends StatelessWidget {
color: AppTheme.success,
onTap: () => context.go('/groups'),
),
].map((w) => SizedBox(
width: isWide
? (constraints.maxWidth - 36) / 4
: (constraints.maxWidth - 12) / 2,
child: w,
)).toList(),
_ActionCard(
label: 'ارسال اعلان عمومی',
icon: Icons.campaign_rounded,
color: const Color(0xFFEA580C),
onTap: () => context.go('/notifications'),
),
]
.map((w) => SizedBox(
width: isWide
? (constraints.maxWidth - 48) / 5
: (constraints.maxWidth - 12) / 2,
child: w,
))
.toList(),
);
});
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:provider/provider.dart';
import '../models/group_member_model.dart';
import '../models/user_model.dart';
@ -34,9 +33,8 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
@override
Widget build(BuildContext context) {
return Consumer<GroupProvider>(builder: (_, groupProvider, __) {
final group = groupProvider.groups
.where((g) => g.id == widget.groupId)
.firstOrNull;
final group =
groupProvider.groups.where((g) => g.id == widget.groupId).firstOrNull;
return ResponsiveLayout(
title: group?.name ?? 'جزئیات گروه',
@ -44,9 +42,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
body: _GroupDetailBody(
groupId: widget.groupId,
groupName: group?.name ?? '...',
groupDescription: group?.description,
isActive: group?.isActive ?? true,
createdAt: group?.createdAt,
),
);
});
@ -56,16 +52,12 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
class _GroupDetailBody extends StatelessWidget {
final String groupId;
final String groupName;
final String? groupDescription;
final bool isActive;
final DateTime? createdAt;
const _GroupDetailBody({
required this.groupId,
required this.groupName,
required this.groupDescription,
required this.isActive,
required this.createdAt,
});
@override
@ -141,26 +133,6 @@ class _GroupDetailBody extends StatelessWidget {
_StatusBadge(isActive: isActive),
],
),
if (groupDescription != null) ...[
const SizedBox(height: 4),
Text(
groupDescription!,
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 14,
),
),
],
const SizedBox(height: 4),
Text(
createdAt != null
? 'ایجاد شده در ${DateFormat('yyyy/MM/dd').format(createdAt!)}'
: '',
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
],
),
),
@ -206,7 +178,6 @@ class _AddMemberDialog extends StatefulWidget {
class _AddMemberDialogState extends State<_AddMemberDialog> {
UserModel? _selectedUser;
GroupRole _role = GroupRole.member;
bool _loading = false;
String? _error;
@ -222,7 +193,7 @@ class _AddMemberDialogState extends State<_AddMemberDialog> {
final provider = context.read<GroupProvider>();
final currentMembers = provider.membersOf(widget.groupId);
if (currentMembers.any((m) => m.userId == _selectedUser!.id)) {
if (currentMembers.any((m) => m.username == _selectedUser!.username)) {
setState(() {
_loading = false;
_error = 'این کاربر قبلاً عضو این گروه است';
@ -230,22 +201,19 @@ class _AddMemberDialogState extends State<_AddMemberDialog> {
return;
}
final result = await provider.addMember(
final success = await provider.inviteMember(
widget.groupId,
_selectedUser!.id,
_role,
_selectedUser!.username,
);
if (!mounted) return;
setState(() => _loading = false);
if (result != null) {
if (success) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('«${_selectedUser!.username}» به گروه اضافه شد'),
content: Text('«${_selectedUser!.username}» به گروه اضافه شد'),
backgroundColor: AppTheme.success,
),
);
@ -291,23 +259,6 @@ class _AddMemberDialogState extends State<_AddMemberDialog> {
.toList(),
onChanged: (v) => setState(() => _selectedUser = v),
),
const SizedBox(height: 16),
// Role selector
DropdownButtonFormField<GroupRole>(
initialValue: _role,
decoration: const InputDecoration(
labelText: 'نقش در گروه',
prefixIcon: Icon(Icons.badge_outlined),
),
items: GroupRole.values
.map((r) => DropdownMenuItem(
value: r,
child: Text(r.label),
))
.toList(),
onChanged: (v) => setState(() => _role = v!),
),
if (_error != null) ...[
const SizedBox(height: 12),
@ -321,8 +272,8 @@ class _AddMemberDialogState extends State<_AddMemberDialog> {
),
child: Text(
_error!,
style: const TextStyle(
color: AppTheme.danger, fontSize: 13),
style:
const TextStyle(color: AppTheme.danger, fontSize: 13),
),
),
],
@ -401,9 +352,10 @@ class _MembersTable extends StatelessWidget {
columns: const [
DataColumn(label: Text('کاربر')),
DataColumn(label: Text('نقش در گروه')),
DataColumn(label: Text('تاریخ عضویت')),
DataColumn(label: Text('وضعیت آنلاین')),
DataColumn(label: Text('عملیات')),
],
rows: members.map((m) => _buildRow(m)).toList(),
rows: members.map((m) => _buildRow(context, m)).toList(),
),
),
),
@ -412,7 +364,7 @@ class _MembersTable extends StatelessWidget {
});
}
DataRow _buildRow(GroupMemberModel member) {
DataRow _buildRow(BuildContext context, GroupMemberModel member) {
final color = member.role == GroupRole.manager
? const Color(0xFF0891B2)
: AppTheme.textSecondary;
@ -461,18 +413,92 @@ class _MembersTable extends StatelessWidget {
),
),
DataCell(
Text(
member.joinedAt != null
? DateFormat('yyyy/MM/dd').format(member.joinedAt!)
: '',
style: const TextStyle(color: AppTheme.textSecondary),
),
_OnlineBadge(isOnline: member.isOnline),
),
DataCell(
_RemoveMemberButton(groupId: groupId, member: member),
),
],
);
}
}
class _RemoveMemberButton extends StatefulWidget {
final String groupId;
final GroupMemberModel member;
const _RemoveMemberButton({required this.groupId, required this.member});
@override
State<_RemoveMemberButton> createState() => _RemoveMemberButtonState();
}
class _RemoveMemberButtonState extends State<_RemoveMemberButton> {
bool _loading = false;
Future<void> _remove() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('تأیید حذف عضو'),
content: Text(
'آیا مطمئن هستید که می‌خواهید «${widget.member.username ?? widget.member.userId}» را از این گروه حذف کنید؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('انصراف'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.danger),
child: const Text('حذف'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _loading = true);
final provider = context.read<GroupProvider>();
final success =
await provider.removeMember(widget.groupId, widget.member.userId);
if (!mounted) return;
setState(() => _loading = false);
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('عضو از گروه حذف شد'),
backgroundColor: AppTheme.success,
),
);
} else if (provider.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(provider.error!),
backgroundColor: AppTheme.danger,
),
);
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2));
}
return IconButton(
onPressed: _remove,
icon: const Icon(Icons.person_remove_rounded, size: 18),
color: AppTheme.danger,
tooltip: 'حذف از گروه',
);
}
}
// Status Badge
class _StatusBadge extends StatelessWidget {
@ -499,3 +525,39 @@ class _StatusBadge extends StatelessWidget {
);
}
}
class _OnlineBadge extends StatelessWidget {
final bool isOnline;
const _OnlineBadge({required this.isOnline});
@override
Widget build(BuildContext context) {
final color = isOnline ? AppTheme.success : AppTheme.textSecondary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(
isOnline ? 'آنلاین' : 'آفلاین',
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:provider/provider.dart';
import '../models/group_model.dart';
import '../providers/group_provider.dart';
@ -125,10 +124,7 @@ class _GroupsBody extends StatelessWidget {
final filtered = provider.groups
.where((g) =>
search.isEmpty ||
g.name.toLowerCase().contains(search) ||
(g.description?.toLowerCase().contains(search) ??
false))
search.isEmpty || g.name.toLowerCase().contains(search))
.toList();
if (filtered.isEmpty) {
@ -167,14 +163,12 @@ class _CreateGroupDialog extends StatefulWidget {
class _CreateGroupDialogState extends State<_CreateGroupDialog> {
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _descCtrl = TextEditingController();
bool _loading = false;
String? _error;
@override
void dispose() {
_nameCtrl.dispose();
_descCtrl.dispose();
super.dispose();
}
@ -188,7 +182,6 @@ class _CreateGroupDialogState extends State<_CreateGroupDialog> {
final provider = context.read<GroupProvider>();
final group = await provider.createGroup(
_nameCtrl.text.trim(),
_descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(),
);
if (!mounted) return;
@ -231,18 +224,9 @@ class _CreateGroupDialogState extends State<_CreateGroupDialog> {
labelText: 'نام گروه',
prefixIcon: Icon(Icons.groups_rounded),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'نام گروه الزامی است' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'توضیحات (اختیاری)',
prefixIcon: Icon(Icons.description_outlined),
alignLabelWithHint: true,
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'نام گروه الزامی است'
: null,
),
if (_error != null) ...[
const SizedBox(height: 12),
@ -256,8 +240,8 @@ class _CreateGroupDialogState extends State<_CreateGroupDialog> {
),
child: Text(
_error!,
style: const TextStyle(
color: AppTheme.danger, fontSize: 13),
style:
const TextStyle(color: AppTheme.danger, fontSize: 13),
),
),
],
@ -303,15 +287,11 @@ class _GroupsTable extends StatelessWidget {
child: DataTable(
columns: const [
DataColumn(label: Text('نام گروه')),
DataColumn(label: Text('توضیحات')),
DataColumn(label: Text('اعضا')),
DataColumn(label: Text('نوع')),
DataColumn(label: Text('وضعیت')),
DataColumn(label: Text('تاریخ ایجاد')),
DataColumn(label: Text('عملیات')),
],
rows: groups
.map((g) => _buildRow(context, g))
.toList(),
rows: groups.map((g) => _buildRow(context, g)).toList(),
),
),
),
@ -347,36 +327,13 @@ class _GroupsTable extends StatelessWidget {
],
),
),
DataCell(
SizedBox(
width: 180,
child: Text(
group.description ?? '',
style: const TextStyle(color: AppTheme.textSecondary),
overflow: TextOverflow.ellipsis,
),
),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.people_rounded,
size: 14, color: AppTheme.textSecondary),
const SizedBox(width: 4),
Text('${group.memberCount}'),
],
),
),
DataCell(_StatusBadge(isActive: group.isActive)),
DataCell(
Text(
group.createdAt != null
? DateFormat('yyyy/MM/dd').format(group.createdAt!)
: '',
group.type.label,
style: const TextStyle(color: AppTheme.textSecondary),
),
),
DataCell(_StatusBadge(isActive: group.isActive)),
DataCell(
TextButton.icon(
onPressed: () => context.go('/groups/${group.id}'),
@ -442,8 +399,7 @@ class _EmptyView extends StatelessWidget {
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
color: AppTheme.textSecondary, fontSize: 16),
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 16),
),
],
),
@ -465,8 +421,7 @@ class _ErrorView extends StatelessWidget {
const Icon(Icons.error_outline_rounded,
size: 64, color: AppTheme.danger),
const SizedBox(height: 16),
Text(message,
style: const TextStyle(color: AppTheme.textSecondary)),
Text(message, style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRetry,

View File

@ -0,0 +1,492 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/notification_model.dart';
import '../providers/notification_provider.dart';
import '../theme/app_theme.dart';
import '../widgets/app_sidebar.dart';
import '../widgets/responsive_layout.dart';
class NotificationsScreen extends StatelessWidget {
const NotificationsScreen({super.key});
@override
Widget build(BuildContext context) {
return const ResponsiveLayout(
title: 'اعلان‌ها',
sidebar: AppSidebar(),
body: _NotificationsBody(),
);
}
}
class _NotificationsBody extends StatefulWidget {
const _NotificationsBody();
@override
State<_NotificationsBody> createState() => _NotificationsBodyState();
}
class _NotificationsBodyState extends State<_NotificationsBody> {
final _searchCtrl = TextEditingController();
String _search = '';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<NotificationProvider>().loadNotifications();
});
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'اعلان‌های سیستم',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppTheme.textPrimary,
),
),
SizedBox(height: 4),
Text(
'مشاهده اعلان‌ها و ارسال اعلان عمومی',
style: TextStyle(
fontSize: 14, color: AppTheme.textSecondary),
),
],
),
),
ElevatedButton.icon(
onPressed: () => _showBroadcastDialog(context),
icon: const Icon(Icons.campaign_rounded, size: 18),
label: const Text('ارسال اعلان عمومی'),
),
],
),
const SizedBox(height: 20),
// Search
TextField(
controller: _searchCtrl,
onChanged: (v) => setState(() => _search = v.toLowerCase()),
decoration: const InputDecoration(
hintText: 'جستجوی اعلان...',
prefixIcon: Icon(Icons.search_rounded),
),
),
const SizedBox(height: 16),
// Table
Expanded(
child: Consumer<NotificationProvider>(
builder: (_, provider, __) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.status == NotificationLoadStatus.error) {
return _ErrorView(
message: provider.error ?? 'خطا',
onRetry: () => provider.loadNotifications(),
);
}
final filtered = provider.items.where((n) {
if (_search.isEmpty) return true;
final text = [
n.title,
n.description ?? '',
n.receiverId,
n.senderId ?? '',
].join(' ').toLowerCase();
return text.contains(_search);
}).toList();
if (filtered.isEmpty) {
return const _EmptyView(
icon: Icons.notifications_none_rounded,
message: 'اعلانی یافت نشد',
);
}
return _NotificationsTable(items: filtered);
},
),
),
],
),
);
}
void _showBroadcastDialog(BuildContext context) {
showDialog(
context: context,
builder: (_) => const _BroadcastDialog(),
);
}
}
// Broadcast Dialog
class _BroadcastDialog extends StatefulWidget {
const _BroadcastDialog();
@override
State<_BroadcastDialog> createState() => _BroadcastDialogState();
}
class _BroadcastDialogState extends State<_BroadcastDialog> {
final _formKey = GlobalKey<FormState>();
final _titleCtrl = TextEditingController();
final _descCtrl = TextEditingController();
bool _loading = false;
String? _error;
bool _sent = false;
@override
void dispose() {
_titleCtrl.dispose();
_descCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_loading = true;
_error = null;
});
final title = _titleCtrl.text.trim();
final desc = _descCtrl.text.trim();
final provider = context.read<NotificationProvider>();
final success = await provider.sendPublic(title, desc);
if (!mounted) return;
setState(() => _loading = false);
if (success) {
setState(() => _sent = true);
} else {
setState(() => _error = provider.error ?? 'خطا در ارسال اعلان');
}
}
@override
Widget build(BuildContext context) {
if (_sent) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.check_circle_rounded, color: AppTheme.success),
SizedBox(width: 10),
Text('اعلان ارسال شد'),
],
),
content: const Text('اعلان عمومی با موفقیت به همه کاربران ارسال شد.'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('بستن'),
),
],
);
}
return AlertDialog(
title: const Row(
children: [
Icon(Icons.campaign_rounded, color: AppTheme.primary),
SizedBox(width: 10),
Text('ارسال اعلان عمومی'),
],
),
content: SizedBox(
width: 440,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _titleCtrl,
decoration: const InputDecoration(
labelText: 'عنوان اعلان',
prefixIcon: Icon(Icons.title_rounded),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'عنوان الزامی است' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descCtrl,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'متن اعلان (اختیاری)',
prefixIcon: Icon(Icons.description_outlined),
alignLabelWithHint: true,
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.danger.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.danger.withValues(alpha: 0.3)),
),
child: Text(
_error!,
style: const TextStyle(
color: AppTheme.danger, fontSize: 13),
),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('انصراف'),
),
ElevatedButton.icon(
onPressed: _loading ? null : _submit,
icon: _loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2))
: const Icon(Icons.send_rounded, size: 16),
label: const Text('ارسال به همه'),
),
],
);
}
}
// Notifications Table
class _NotificationsTable extends StatelessWidget {
final List<NotificationModel> items;
const _NotificationsTable({required this.items});
@override
Widget build(BuildContext context) {
return Card(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: const [
DataColumn(label: Text('عنوان')),
DataColumn(label: Text('نوع')),
DataColumn(label: Text('گیرنده')),
DataColumn(label: Text('وضعیت')),
],
rows: items.map((n) => _buildRow(n)).toList(),
),
),
),
),
);
}
DataRow _buildRow(NotificationModel n) {
return DataRow(
cells: [
DataCell(
SizedBox(
width: 280,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
n.title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
if ((n.description ?? '').isNotEmpty) ...[
const SizedBox(height: 4),
Text(
n.description!,
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 12,
),
),
],
if (n.groupId != null) ...[
const SizedBox(height: 4),
Text(
'گروه: ${n.groupId}',
style: const TextStyle(
color: AppTheme.textSecondary,
fontSize: 11,
),
),
],
],
),
),
),
DataCell(_TypeBadge(type: n.type)),
DataCell(
Text(
n.receiverId,
style: const TextStyle(color: AppTheme.textSecondary),
),
),
DataCell(_StatusBadge(type: n.type, isAccepted: n.isAccepted)),
],
);
}
}
class _TypeBadge extends StatelessWidget {
final NotificationType type;
const _TypeBadge({required this.type});
@override
Widget build(BuildContext context) {
final color = type == NotificationType.public
? AppTheme.primary
: const Color(0xFF0891B2);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Text(
type.label,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _StatusBadge extends StatelessWidget {
final NotificationType type;
final bool? isAccepted;
const _StatusBadge({required this.type, required this.isAccepted});
@override
Widget build(BuildContext context) {
String label;
Color color;
if (type == NotificationType.public) {
label = 'ارسال شد';
color = AppTheme.success;
} else {
if (isAccepted == true) {
label = 'تأیید شد';
color = AppTheme.success;
} else if (isAccepted == false) {
label = 'رد شد';
color = AppTheme.danger;
} else {
label = 'در انتظار';
color = AppTheme.warning;
}
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
class _EmptyView extends StatelessWidget {
final IconData icon;
final String message;
const _EmptyView({required this.icon, required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: AppTheme.border),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 16),
),
],
),
);
}
}
class _ErrorView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorView({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline_rounded,
size: 64, color: AppTheme.danger),
const SizedBox(height: 16),
Text(message, style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('تلاش مجدد'),
),
],
),
);
}
}

View File

@ -125,8 +125,7 @@ class _UsersBody extends StatelessWidget {
final filtered = provider.users
.where((u) =>
search.isEmpty ||
u.username.toLowerCase().contains(search) ||
u.role.label.toLowerCase().contains(search))
u.username.toLowerCase().contains(search))
.toList();
if (filtered.isEmpty) {
@ -165,13 +164,14 @@ class _CreateUserDialog extends StatefulWidget {
class _CreateUserDialogState extends State<_CreateUserDialog> {
final _formKey = GlobalKey<FormState>();
final _usernameCtrl = TextEditingController();
UserRole _role = UserRole.member;
final _phoneCtrl = TextEditingController();
bool _loading = false;
String? _error;
@override
void dispose() {
_usernameCtrl.dispose();
_phoneCtrl.dispose();
super.dispose();
}
@ -185,7 +185,8 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
final provider = context.read<UserProvider>();
final result = await provider.createUser(
_usernameCtrl.text.trim(),
_role,
phoneNumber:
_phoneCtrl.text.trim().isEmpty ? null : _phoneCtrl.text.trim(),
);
if (!mounted) return;
@ -228,23 +229,27 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
labelText: 'نام کاربری',
prefixIcon: Icon(Icons.person_outline_rounded),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'نام کاربری الزامی است' : null,
validator: (v) => (v == null || v.trim().isEmpty)
? 'نام کاربری الزامی است'
: null,
),
const SizedBox(height: 16),
DropdownButtonFormField<UserRole>(
initialValue: _role,
TextFormField(
controller: _phoneCtrl,
textDirection: TextDirection.ltr,
decoration: const InputDecoration(
labelText: 'نقش',
prefixIcon: Icon(Icons.badge_outlined),
labelText: 'شماره همراه (اختیاری)',
prefixIcon: Icon(Icons.phone_android_rounded),
),
items: UserRole.values
.map((r) => DropdownMenuItem(
value: r,
child: Text(r.label),
))
.toList(),
onChanged: (v) => setState(() => _role = v!),
validator: (v) {
if (v != null && v.trim().isNotEmpty) {
if (v.trim().length != 11 ||
!RegExp(r'^\d+$').hasMatch(v.trim())) {
return 'شماره همراه معتبر نیست (۱۱ رقم)';
}
}
return null;
},
),
if (_error != null) ...[
const SizedBox(height: 12),
@ -253,11 +258,13 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
decoration: BoxDecoration(
color: AppTheme.danger.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.danger.withValues(alpha: 0.3)),
border: Border.all(
color: AppTheme.danger.withValues(alpha: 0.3)),
),
child: Text(
_error!,
style: const TextStyle(color: AppTheme.danger, fontSize: 13),
style:
const TextStyle(color: AppTheme.danger, fontSize: 13),
),
),
],
@ -303,6 +310,7 @@ class _UsersTable extends StatelessWidget {
child: DataTable(
columns: const [
DataColumn(label: Text('نام کاربری')),
DataColumn(label: Text('شماره همراه')),
DataColumn(label: Text('نقش')),
DataColumn(label: Text('وضعیت')),
DataColumn(label: Text('تاریخ ایجاد')),
@ -343,6 +351,12 @@ class _UsersTable extends StatelessWidget {
],
),
),
DataCell(
Text(
user.phoneNumber ?? '',
style: const TextStyle(color: AppTheme.textSecondary),
),
),
DataCell(_RoleBadge(role: user.role)),
DataCell(_StatusBadge(isActive: user.isActive)),
DataCell(
@ -354,7 +368,14 @@ class _UsersTable extends StatelessWidget {
),
),
DataCell(
_ResetSecretButton(user: user),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_ResetSecretButton(user: user),
const SizedBox(width: 8),
_LogoutUserButton(user: user),
],
),
),
],
);
@ -513,8 +534,7 @@ class _EmptyView extends StatelessWidget {
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
color: AppTheme.textSecondary, fontSize: 16),
style: const TextStyle(color: AppTheme.textSecondary, fontSize: 16),
),
],
),
@ -536,8 +556,7 @@ class _ErrorView extends StatelessWidget {
const Icon(Icons.error_outline_rounded,
size: 64, color: AppTheme.danger),
const SizedBox(height: 16),
Text(message,
style: const TextStyle(color: AppTheme.textSecondary)),
Text(message, style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRetry,
@ -549,3 +568,77 @@ class _ErrorView extends StatelessWidget {
);
}
}
class _LogoutUserButton extends StatefulWidget {
final UserModel user;
const _LogoutUserButton({required this.user});
@override
State<_LogoutUserButton> createState() => _LogoutUserButtonState();
}
class _LogoutUserButtonState extends State<_LogoutUserButton> {
bool _loading = false;
Future<void> _logout() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('تأیید خروج اجباری'),
content: Text(
'آیا مطمئن هستید که می‌خواهید «${widget.user.username}» را از تمام دستگاه‌ها خارج کنید؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('انصراف'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.danger),
child: const Text('خروج اجباری'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _loading = true);
final provider = context.read<UserProvider>();
final success = await provider.logoutUser(widget.user.id);
if (!mounted) return;
setState(() => _loading = false);
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('کاربر با موفقیت خارج شد'),
backgroundColor: AppTheme.success,
),
);
} else if (provider.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(provider.error!),
backgroundColor: AppTheme.danger,
),
);
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2));
}
return TextButton.icon(
onPressed: _logout,
icon: const Icon(Icons.logout_rounded, size: 16),
label: const Text('خروج'),
style: TextButton.styleFrom(foregroundColor: AppTheme.danger),
);
}
}

View File

@ -42,13 +42,23 @@ class ApiClient {
return _handleResponse(response);
}
Future<dynamic> delete(String path) async {
final response = await http.delete(
Uri.parse('$baseUrl$path'),
headers: _headers,
);
return _handleResponse(response);
}
dynamic _handleResponse(http.Response response) {
if (response.body.isEmpty) return null;
final data = jsonDecode(response.body);
if (response.statusCode >= 200 && response.statusCode < 300) {
return data;
}
final detail = data is Map ? (data['detail'] ?? 'Unknown error') : 'Unknown error';
throw ApiException(statusCode: response.statusCode, message: detail.toString());
final detail =
data is Map ? (data['detail'] ?? 'Unknown error') : 'Unknown error';
throw ApiException(
statusCode: response.statusCode, message: detail.toString());
}
}

View File

@ -4,58 +4,42 @@ import '../interfaces/group_service.dart';
import 'api_client.dart';
/// Real API implementation.
/// NOTE: The backend has no list-groups or list-members endpoint.
/// Groups and members are tracked in memory per session.
class GroupApiService implements GroupService {
final ApiClient _client;
final List<GroupModel> _sessionGroups = [];
final Map<String, List<GroupMemberModel>> _sessionMembers = {};
GroupApiService(this._client);
@override
Future<List<GroupModel>> getGroups() async {
return List.unmodifiable(_sessionGroups);
final List<dynamic> data = await _client.get('/admin/groups');
return data
.map((json) => GroupModel.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<GroupModel> createGroup(String name, String? description) async {
final body = <String, dynamic>{'name': name};
if (description != null && description.isNotEmpty) {
body['description'] = description;
}
final data = await _client.post('/groups/', body);
final group = GroupModel.fromJson(data as Map<String, dynamic>);
_sessionGroups.add(group);
_sessionMembers[group.id] = [];
return group;
Future<GroupModel> createGroup(String name) async {
final data = await _client.post('/groups/', {'name': name});
return GroupModel.fromJson(data as Map<String, dynamic>);
}
@override
Future<GroupMemberModel> addMember(
String groupId,
String userId,
GroupRole role,
) async {
final data = await _client.post('/groups/$groupId/members', {
'user_id': userId,
'role': role.apiValue,
Future<void> inviteMember(String groupId, String username) async {
await _client.post('/groups/$groupId/invite', {
'username': username,
});
final member = GroupMemberModel.fromJson(data as Map<String, dynamic>);
_sessionMembers.putIfAbsent(groupId, () => []).add(member);
}
// Update member count on the cached group
final idx = _sessionGroups.indexWhere((g) => g.id == groupId);
if (idx != -1) {
_sessionGroups[idx] = _sessionGroups[idx].copyWith(
memberCount: (_sessionMembers[groupId]?.length ?? 1),
);
}
return member;
@override
Future<void> removeMember(String groupId, String userId) async {
await _client.delete('/groups/$groupId/members/$userId');
}
@override
Future<List<GroupMemberModel>> getGroupMembers(String groupId) async {
return List.unmodifiable(_sessionMembers[groupId] ?? []);
final List<dynamic> data = await _client.get('/groups/$groupId/members');
return data
.map((json) => GroupMemberModel.fromJson(json as Map<String, dynamic>))
.toList();
}
}

View File

@ -0,0 +1,27 @@
import '../../models/notification_model.dart';
import '../interfaces/notification_service.dart';
import 'api_client.dart';
class NotificationApiService implements NotificationService {
final ApiClient _client;
NotificationApiService(this._client);
@override
Future<NotificationList> getNotifications() async {
final List<dynamic> data = await _client.get('/admin/notifications');
return data
.map((json) => NotificationModel.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> sendPublicNotification(String title, String description) async {
final encodedTitle = Uri.encodeQueryComponent(title);
final encodedDesc = Uri.encodeQueryComponent(description);
await _client.post(
'/notifications/public?title=$encodedTitle&description=$encodedDesc',
{},
);
}
}

View File

@ -3,28 +3,30 @@ import '../interfaces/user_service.dart';
import 'api_client.dart';
/// Real API implementation.
/// NOTE: The backend has no list-users endpoint, so [getUsers] returns only
/// users created during the current session (stored in memory).
class UserApiService implements UserService {
final ApiClient _client;
final List<UserModel> _sessionUsers = [];
UserApiService(this._client);
@override
Future<List<UserModel>> getUsers() async {
return List.unmodifiable(_sessionUsers);
final List<dynamic> data = await _client.get('/admin/users');
return data
.map((json) => UserModel.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<CreateUserResult> createUser(String username, UserRole role) async {
Future<CreateUserResult> createUser(
String username, {
String? phoneNumber,
}) async {
final data = await _client.post('/admin/users', {
'username': username,
'role': role.apiValue,
if (phoneNumber != null) 'phone_number': phoneNumber,
});
final user = UserModel.fromJson(data['user'] as Map<String, dynamic>);
final secret = data['secret'] as String;
_sessionUsers.add(user);
return (user: user, secret: secret);
}
@ -36,4 +38,9 @@ class UserApiService implements UserService {
);
return data['secret'] as String;
}
@override
Future<void> logoutUser(String userId) async {
await _client.post('/admin/users/$userId/logout', {});
}
}

View File

@ -6,14 +6,13 @@ abstract class GroupService {
Future<List<GroupModel>> getGroups();
/// Creates a new group.
Future<GroupModel> createGroup(String name, String? description);
Future<GroupModel> createGroup(String name);
/// Adds a user to a group with the given role.
Future<GroupMemberModel> addMember(
String groupId,
String userId,
GroupRole role,
);
/// Invites a user to a group by username.
Future<void> inviteMember(String groupId, String username);
/// Removes a member from a group.
Future<void> removeMember(String groupId, String userId);
/// Returns all members of a group.
Future<List<GroupMemberModel>> getGroupMembers(String groupId);

View File

@ -0,0 +1,11 @@
import '../../models/notification_model.dart';
typedef NotificationList = List<NotificationModel>;
abstract class NotificationService {
/// Returns all notifications for admin.
Future<NotificationList> getNotifications();
/// Broadcasts a public notification to all users.
Future<void> sendPublicNotification(String title, String description);
}

View File

@ -4,12 +4,17 @@ typedef CreateUserResult = ({UserModel user, String secret});
abstract class UserService {
/// Returns all users.
/// In real-API mode, returns only in-session created users (no list endpoint).
Future<List<UserModel>> getUsers();
/// Creates a new user and returns the user along with the generated secret.
Future<CreateUserResult> createUser(String username, UserRole role);
Future<CreateUserResult> createUser(
String username, {
String? phoneNumber,
});
/// Resets the secret for [userId] and returns the new secret.
Future<String> resetSecret(String userId);
/// Logs out the user with the given [userId] by incrementing their token version.
Future<void> logoutUser(String userId);
}

View File

@ -10,84 +10,72 @@ class MockData {
username: 'admin',
role: UserRole.admin,
isActive: true,
createdAt: DateTime(2025, 1, 10),
),
UserModel(
id: 'u-0002',
username: 'ali_karimi',
role: UserRole.group_manager,
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 2, 3),
),
UserModel(
id: 'u-0003',
username: 'sara_mohammadi',
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 2, 5),
),
UserModel(
id: 'u-0004',
username: 'reza_ahmadi',
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 2, 10),
),
UserModel(
id: 'u-0005',
username: 'maryam_hosseini',
role: UserRole.group_manager,
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 2, 15),
),
UserModel(
id: 'u-0006',
username: 'javad_rezaei',
role: UserRole.member,
isActive: false,
createdAt: DateTime(2025, 3, 1),
),
UserModel(
id: 'u-0007',
username: 'nasrin_bagheri',
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 3, 5),
),
UserModel(
id: 'u-0008',
username: 'hamed_safari',
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 3, 10),
),
UserModel(
id: 'u-0009',
username: 'leila_moradi',
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 4, 2),
),
UserModel(
id: 'u-0010',
username: 'mehdi_tavakoli',
role: UserRole.group_manager,
role: UserRole.member,
isActive: false,
createdAt: DateTime(2025, 4, 8),
),
UserModel(
id: 'u-0011',
username: 'fatemeh_nazari',
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 5, 1),
),
UserModel(
id: 'u-0012',
username: 'omid_shahidi',
role: UserRole.member,
isActive: true,
createdAt: DateTime(2025, 5, 15),
),
];
@ -95,77 +83,147 @@ class MockData {
GroupModel(
id: 'g-0001',
name: 'تیم آلفا',
description: 'واحد عملیاتی اصلی',
isActive: true,
type: GroupType.group,
createdAt: DateTime(2025, 1, 15),
memberCount: 4,
type: GroupType.public,
),
GroupModel(
id: 'g-0002',
name: 'تیم براوو',
description: 'واحد پشتیبانی و لجستیک',
isActive: true,
type: GroupType.group,
createdAt: DateTime(2025, 1, 20),
memberCount: 3,
type: GroupType.public,
),
GroupModel(
id: 'g-0003',
name: 'مرکز فرماندهی',
description: 'هماهنگی مرکزی تمام تیم‌ها',
isActive: true,
type: GroupType.group,
createdAt: DateTime(2025, 2, 1),
memberCount: 5,
type: GroupType.public,
),
GroupModel(
id: 'g-0004',
name: 'لجستیک',
description: 'مدیریت تجهیزات و منابع',
isActive: true,
type: GroupType.group,
createdAt: DateTime(2025, 2, 10),
memberCount: 3,
type: GroupType.public,
),
GroupModel(
id: 'g-0005',
name: 'واکنش اضطراری',
description: 'تیم پاسخ سریع به حوادث',
isActive: false,
type: GroupType.group,
createdAt: DateTime(2025, 3, 5),
memberCount: 2,
type: GroupType.public,
),
];
static final Map<String, List<GroupMemberModel>> memberships = {
'g-0001': [
GroupMemberModel(userId: 'u-0002', groupId: 'g-0001', role: GroupRole.manager, username: 'ali_karimi', joinedAt: DateTime(2025, 1, 15)),
GroupMemberModel(userId: 'u-0003', groupId: 'g-0001', role: GroupRole.member, username: 'sara_mohammadi', joinedAt: DateTime(2025, 1, 16)),
GroupMemberModel(userId: 'u-0004', groupId: 'g-0001', role: GroupRole.member, username: 'reza_ahmadi', joinedAt: DateTime(2025, 1, 17)),
GroupMemberModel(userId: 'u-0007', groupId: 'g-0001', role: GroupRole.member, username: 'nasrin_bagheri', joinedAt: DateTime(2025, 2, 1)),
GroupMemberModel(
userId: 'u-0002',
role: GroupRole.manager,
username: 'ali_karimi',
isOnline: true,
),
GroupMemberModel(
userId: 'u-0003',
role: GroupRole.member,
username: 'sara_mohammadi',
isOnline: false,
),
GroupMemberModel(
userId: 'u-0004',
role: GroupRole.member,
username: 'reza_ahmadi',
isOnline: true,
),
GroupMemberModel(
userId: 'u-0007',
role: GroupRole.member,
username: 'nasrin_bagheri',
isOnline: false,
),
],
'g-0002': [
GroupMemberModel(userId: 'u-0005', groupId: 'g-0002', role: GroupRole.manager, username: 'maryam_hosseini', joinedAt: DateTime(2025, 1, 20)),
GroupMemberModel(userId: 'u-0008', groupId: 'g-0002', role: GroupRole.member, username: 'hamed_safari', joinedAt: DateTime(2025, 1, 21)),
GroupMemberModel(userId: 'u-0009', groupId: 'g-0002', role: GroupRole.member, username: 'leila_moradi', joinedAt: DateTime(2025, 1, 22)),
GroupMemberModel(
userId: 'u-0005',
role: GroupRole.manager,
username: 'maryam_hosseini',
isOnline: true,
),
GroupMemberModel(
userId: 'u-0008',
role: GroupRole.member,
username: 'hamed_safari',
isOnline: false,
),
GroupMemberModel(
userId: 'u-0009',
role: GroupRole.member,
username: 'leila_moradi',
isOnline: true,
),
],
'g-0003': [
GroupMemberModel(userId: 'u-0001', groupId: 'g-0003', role: GroupRole.manager, username: 'admin', joinedAt: DateTime(2025, 2, 1)),
GroupMemberModel(userId: 'u-0002', groupId: 'g-0003', role: GroupRole.member, username: 'ali_karimi', joinedAt: DateTime(2025, 2, 2)),
GroupMemberModel(userId: 'u-0005', groupId: 'g-0003', role: GroupRole.member, username: 'maryam_hosseini', joinedAt: DateTime(2025, 2, 3)),
GroupMemberModel(userId: 'u-0010', groupId: 'g-0003', role: GroupRole.member, username: 'mehdi_tavakoli', joinedAt: DateTime(2025, 2, 5)),
GroupMemberModel(userId: 'u-0011', groupId: 'g-0003', role: GroupRole.member, username: 'fatemeh_nazari', joinedAt: DateTime(2025, 2, 10)),
GroupMemberModel(
userId: 'u-0001',
role: GroupRole.manager,
username: 'admin',
isOnline: true,
),
GroupMemberModel(
userId: 'u-0002',
role: GroupRole.member,
username: 'ali_karimi',
isOnline: true,
),
GroupMemberModel(
userId: 'u-0005',
role: GroupRole.member,
username: 'maryam_hosseini',
isOnline: false,
),
GroupMemberModel(
userId: 'u-0010',
role: GroupRole.member,
username: 'mehdi_tavakoli',
isOnline: false,
),
GroupMemberModel(
userId: 'u-0011',
role: GroupRole.member,
username: 'fatemeh_nazari',
isOnline: true,
),
],
'g-0004': [
GroupMemberModel(userId: 'u-0010', groupId: 'g-0004', role: GroupRole.manager, username: 'mehdi_tavakoli', joinedAt: DateTime(2025, 2, 10)),
GroupMemberModel(userId: 'u-0011', groupId: 'g-0004', role: GroupRole.member, username: 'fatemeh_nazari', joinedAt: DateTime(2025, 2, 11)),
GroupMemberModel(userId: 'u-0012', groupId: 'g-0004', role: GroupRole.member, username: 'omid_shahidi', joinedAt: DateTime(2025, 2, 12)),
GroupMemberModel(
userId: 'u-0010',
role: GroupRole.manager,
username: 'mehdi_tavakoli',
isOnline: true,
),
GroupMemberModel(
userId: 'u-0011',
role: GroupRole.member,
username: 'fatemeh_nazari',
isOnline: false,
),
GroupMemberModel(
userId: 'u-0012',
role: GroupRole.member,
username: 'omid_shahidi',
isOnline: true,
),
],
'g-0005': [
GroupMemberModel(userId: 'u-0002', groupId: 'g-0005', role: GroupRole.manager, username: 'ali_karimi', joinedAt: DateTime(2025, 3, 5)),
GroupMemberModel(userId: 'u-0006', groupId: 'g-0005', role: GroupRole.member, username: 'javad_rezaei', joinedAt: DateTime(2025, 3, 6)),
GroupMemberModel(
userId: 'u-0002',
role: GroupRole.manager,
username: 'ali_karimi',
isOnline: false,
),
GroupMemberModel(
userId: 'u-0006',
role: GroupRole.member,
username: 'javad_rezaei',
isOnline: false,
),
],
};
}

View File

@ -8,8 +8,7 @@ import 'mock_data.dart';
class MockGroupService implements GroupService {
final List<GroupModel> _groups = List.from(MockData.groups);
final Map<String, List<GroupMemberModel>> _members = {
for (final e in MockData.memberships.entries)
e.key: List.from(e.value),
for (final e in MockData.memberships.entries) e.key: List.from(e.value),
};
final _uuid = const Uuid();
@ -20,17 +19,14 @@ class MockGroupService implements GroupService {
}
@override
Future<GroupModel> createGroup(String name, String? description) async {
Future<GroupModel> createGroup(String name) async {
await Future.delayed(const Duration(milliseconds: 500));
final group = GroupModel(
id: 'g-${_uuid.v4().substring(0, 8)}',
name: name,
description: description,
isActive: true,
type: GroupType.group,
createdAt: DateTime.now(),
memberCount: 0,
type: GroupType.public,
);
_groups.add(group);
_members[group.id] = [];
@ -38,11 +34,7 @@ class MockGroupService implements GroupService {
}
@override
Future<GroupMemberModel> addMember(
String groupId,
String userId,
GroupRole role,
) async {
Future<void> inviteMember(String groupId, String username) async {
await Future.delayed(const Duration(milliseconds: 400));
final groupIdx = _groups.indexWhere((g) => g.id == groupId);
@ -51,23 +43,21 @@ class MockGroupService implements GroupService {
}
final existingMembers = _members.putIfAbsent(groupId, () => []);
if (existingMembers.any((m) => m.userId == userId)) {
throw const ApiException(statusCode: 400, message: 'کاربر قبلاً عضو این گروه است');
}
final member = GroupMemberModel(
userId: userId,
groupId: groupId,
role: role,
joinedAt: DateTime.now(),
userId: _uuid.v4().substring(0, 8),
username: username,
role: GroupRole.member,
);
existingMembers.add(member);
}
_groups[groupIdx] = _groups[groupIdx].copyWith(
memberCount: existingMembers.length,
);
return member;
@override
Future<void> removeMember(String groupId, String userId) async {
await Future.delayed(const Duration(milliseconds: 300));
final members = _members[groupId];
if (members != null) {
members.removeWhere((m) => m.userId == userId);
}
}
@override

View File

@ -0,0 +1,53 @@
import '../../models/notification_model.dart';
import '../interfaces/notification_service.dart';
import '../api/api_client.dart';
class MockNotificationService implements NotificationService {
final List<NotificationModel> _items = [
const NotificationModel(
id: 'n-0001',
title: 'به‌روزرسانی سامانه',
description: 'ساعت ۲۲ سرویس برای ۱۵ دقیقه در دسترس نیست.',
type: NotificationType.public,
isAccepted: null,
receiverId: 'all',
senderId: 'admin',
groupId: null,
),
const NotificationModel(
id: 'n-0002',
title: 'درخواست عضویت',
description: 'کاربر ali_karimi درخواست عضویت ارسال کرده است.',
type: NotificationType.joinRequest,
isAccepted: null,
receiverId: 'admin',
senderId: 'u-0002',
groupId: 'g-0001',
),
];
@override
Future<NotificationList> getNotifications() async {
await Future.delayed(const Duration(milliseconds: 350));
return List.unmodifiable(_items);
}
@override
Future<void> sendPublicNotification(String title, String description) async {
await Future.delayed(const Duration(milliseconds: 300));
if (title.trim().isEmpty) {
throw const ApiException(statusCode: 400, message: 'عنوان الزامی است');
}
final item = NotificationModel(
id: 'n-${_items.length + 1}',
title: title,
description: description,
type: NotificationType.public,
isAccepted: null,
receiverId: 'all',
senderId: 'admin',
groupId: null,
);
_items.insert(0, item);
}
}

View File

@ -15,18 +15,23 @@ class MockUserService implements UserService {
}
@override
Future<CreateUserResult> createUser(String username, UserRole role) async {
Future<CreateUserResult> createUser(
String username, {
String? phoneNumber,
}) async {
await Future.delayed(const Duration(milliseconds: 500));
if (_users.any((u) => u.username == username)) {
throw const ApiException(statusCode: 400, message: 'این نام کاربری قبلاً ثبت شده است');
throw const ApiException(
statusCode: 400, message: 'این نام کاربری قبلاً ثبت شده است');
}
final secret = _generateSecret();
final user = UserModel(
id: 'u-${_uuid.v4().substring(0, 8)}',
username: username,
role: role,
phoneNumber: phoneNumber,
role: UserRole.member,
isActive: true,
createdAt: DateTime.now(),
);
@ -45,6 +50,15 @@ class MockUserService implements UserService {
return _generateSecret();
}
@override
Future<void> logoutUser(String userId) async {
await Future.delayed(const Duration(milliseconds: 300));
final idx = _users.indexWhere((u) => u.id == userId);
if (idx == -1) {
throw const ApiException(statusCode: 404, message: 'کاربر یافت نشد');
}
}
String _generateSecret() {
final uuid = _uuid.v4().replaceAll('-', '');
return uuid.substring(0, 16);

View File

@ -2,13 +2,16 @@ import '../config/app_config.dart';
import 'interfaces/auth_service.dart';
import 'interfaces/user_service.dart';
import 'interfaces/group_service.dart';
import 'interfaces/notification_service.dart';
import 'api/api_client.dart';
import 'api/auth_api_service.dart';
import 'api/user_api_service.dart';
import 'api/group_api_service.dart';
import 'api/notification_api_service.dart';
import 'mock/mock_auth_service.dart';
import 'mock/mock_user_service.dart';
import 'mock/mock_group_service.dart';
import 'mock/mock_notification_service.dart';
/// Singleton that wires together the correct service implementations
/// based on [AppConfig.debugMode].
@ -20,6 +23,7 @@ class ServiceLocator {
late final AuthService auth;
late final UserService users;
late final GroupService groups;
late final NotificationService notifications;
ApiClient? _apiClient;
@ -28,14 +32,24 @@ class ServiceLocator {
auth = MockAuthService();
users = MockUserService();
groups = MockGroupService();
notifications = MockNotificationService();
} else {
_apiClient = ApiClient(baseUrl: AppConfig.baseUrl);
auth = AuthApiService(_apiClient!);
users = UserApiService(_apiClient!);
groups = GroupApiService(_apiClient!);
notifications = NotificationApiService(_apiClient!);
}
}
void setToken(String token) => _apiClient?.setToken(token);
void clearToken() => _apiClient?.clearToken();
/// Exposed for one-off direct API calls (e.g. notifications).
ApiClient get apiClient {
if (_apiClient == null) {
throw StateError('ApiClient not initialized running in debug mode?');
}
return _apiClient!;
}
}

View File

@ -22,8 +22,6 @@ class AppTheme {
switch (role) {
case 'admin':
return const Color(0xFF7C3AED);
case 'group_manager':
return const Color(0xFF0891B2);
default:
return const Color(0xFF64748B);
}

View File

@ -9,9 +9,14 @@ class AppSidebar extends StatelessWidget {
const AppSidebar({super.key});
static const _navItems = [
_NavItem(label: 'داشبورد', icon: Icons.dashboard_rounded, route: '/dashboard'),
_NavItem(
label: 'داشبورد', icon: Icons.dashboard_rounded, route: '/dashboard'),
_NavItem(label: 'کاربران', icon: Icons.people_rounded, route: '/users'),
_NavItem(label: 'گروه‌ها', icon: Icons.groups_rounded, route: '/groups'),
_NavItem(
label: 'اعلان‌ها',
icon: Icons.campaign_rounded,
route: '/notifications'),
];
@override
@ -75,7 +80,8 @@ class AppSidebar extends StatelessWidget {
decoration: BoxDecoration(
color: AppTheme.warning.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppTheme.warning.withValues(alpha: 0.4)),
border:
Border.all(color: AppTheme.warning.withValues(alpha: 0.4)),
),
child: const Row(
children: [

45
call/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
call/.metadata Normal file
View File

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "cd9e44ff10cac283eafc97d6ed58720c45f1be9e"
channel: "master"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
base_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
- platform: android
create_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
base_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
- platform: ios
create_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
base_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
- platform: linux
create_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
base_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
- platform: macos
create_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
base_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
- platform: web
create_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
base_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
- platform: windows
create_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
base_revision: cd9e44ff10cac283eafc97d6ed58720c45f1be9e
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

3
call/README.md Normal file
View File

@ -0,0 +1,3 @@
# call
A new Flutter project.

View File

@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml

14
call/android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.call"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.call"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,50 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- ── Audio permissions ─────────────────────────────────────────── -->
<!-- Capture microphone: records ambient audio containing FSK signal -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Route audio to speaker / modify call volume -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Needed by permission_handler to check/request at runtime -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Vibrate for PTT feedback -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Wake lock: keep CPU alive during active encoding/decoding -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Hardware feature declarations (not required, improves store listing) -->
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<application
android:label="SecureCall"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,288 @@
package com.example.call
import android.content.Context
import android.media.*
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicBoolean
/**
* AudioEngine Android native layer for low-latency audio I/O.
*
* MethodChannel "com.example.call/audio_control"
* startCapture(sampleRate: Int, source: Int) "ok"
* stopCapture() "ok"
* startPlayback(sampleRate: Int) "ok"
* stopPlayback() "ok"
* writePlayback(samples: ByteArray) "ok" (Int16 LE samples)
* setSpeakerMode(enabled: Boolean) "ok"
* getAudioRouteInfo() Map<String,Any>
*
* EventChannel "com.example.call/audio_capture"
* Streams ByteArray (Int16 LE) chunks captured from AudioRecord.
* Each event is exactly CAPTURE_CHUNK_SAMPLES × 2 bytes.
*/
object AudioEngine {
private const val TAG = "AudioEngine"
// ── Channel names ────────────────────────────────────────────────
const val METHOD_CHANNEL = "com.example.call/audio_control"
const val EVENT_CHANNEL = "com.example.call/audio_capture"
// ── Audio parameters ─────────────────────────────────────────────
// Matches the modem sample rate; must be 8000 Hz for cellular compatibility.
private const val SAMPLE_RATE = 8000
// Number of Int16 samples delivered per EventChannel event.
// 160 samples = 20 ms at 8 kHz → one LPC subframe.
private const val CAPTURE_CHUNK_SAMPLES = 160
// Internal AudioRecord buffer: 4× chunk size to reduce overruns.
private const val RECORD_BUFFER_SAMPLES = CAPTURE_CHUNK_SAMPLES * 4
// Internal AudioTrack buffer: 80 ms headroom.
private const val PLAYBACK_BUFFER_SAMPLES = SAMPLE_RATE / 1000 * 80
// ── State ────────────────────────────────────────────────────────
private var audioRecord: AudioRecord? = null
private var audioTrack: AudioTrack? = null
private var captureThread: Thread? = null
private val capturing = AtomicBoolean(false)
private val playing = AtomicBoolean(false)
private var eventSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
private lateinit var appContext: Context
// ── Registration ─────────────────────────────────────────────────
fun register(context: Context, messenger: BinaryMessenger) {
appContext = context.applicationContext
MethodChannel(messenger, METHOD_CHANNEL).setMethodCallHandler { call, result ->
handleMethod(call, result)
}
EventChannel(messenger, EVENT_CHANNEL).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
eventSink = sink
}
override fun onCancel(args: Any?) {
eventSink = null
}
})
}
// ── Method handler ───────────────────────────────────────────────
private fun handleMethod(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"startCapture" -> {
val rate = call.argument<Int>("sampleRate") ?: SAMPLE_RATE
val source = call.argument<Int>("source") ?: MediaRecorder.AudioSource.UNPROCESSED
startCapture(rate, source)
result.success("ok")
}
"stopCapture" -> {
stopCapture()
result.success("ok")
}
"startPlayback" -> {
val rate = call.argument<Int>("sampleRate") ?: SAMPLE_RATE
startPlayback(rate)
result.success("ok")
}
"stopPlayback" -> {
stopPlayback()
result.success("ok")
}
"writePlayback" -> {
val bytes = call.argument<ByteArray>("samples")
if (bytes != null) writePlayback(bytes)
result.success("ok")
}
"setSpeakerMode" -> {
val enabled = call.argument<Boolean>("enabled") ?: true
setSpeakerMode(enabled)
result.success("ok")
}
"getAudioRouteInfo" -> {
result.success(getAudioRouteInfo())
}
else -> result.notImplemented()
}
}
// ── Capture ──────────────────────────────────────────────────────
/**
* Start capturing audio from the microphone.
*
* Source priority (best for FSK demodulation):
* 1. UNPROCESSED (API 24+) raw mic, no AEC/NS
* 2. VOICE_COMMUNICATION VoIP-tuned processing
* 3. MIC standard mic
*
* @param sampleRate Hz
* @param preferredSource MediaRecorder.AudioSource constant
*/
private fun startCapture(sampleRate: Int, preferredSource: Int) {
if (capturing.get()) {
Log.w(TAG, "startCapture called while already capturing")
return
}
val sources = listOf(
preferredSource,
MediaRecorder.AudioSource.UNPROCESSED,
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
MediaRecorder.AudioSource.MIC
).distinct()
val bufferBytes = RECORD_BUFFER_SAMPLES * 2 // Int16 → 2 bytes each
val format = AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
var record: AudioRecord? = null
for (src in sources) {
try {
val rec = AudioRecord.Builder()
.setAudioSource(src)
.setAudioFormat(format)
.setBufferSizeInBytes(bufferBytes)
.build()
if (rec.state == AudioRecord.STATE_INITIALIZED) {
record = rec
Log.i(TAG, "AudioRecord opened with source=$src")
break
} else {
rec.release()
}
} catch (e: Exception) {
Log.w(TAG, "Source $src failed: ${e.message}")
}
}
if (record == null) {
Log.e(TAG, "Could not open AudioRecord with any source")
return
}
audioRecord = record
capturing.set(true)
captureThread = Thread({
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_URGENT_AUDIO)
val chunk = ShortArray(CAPTURE_CHUNK_SAMPLES)
record.startRecording()
while (capturing.get()) {
val read = record.read(chunk, 0, chunk.size)
if (read > 0) {
// Convert ShortArray → ByteArray (Int16 LE) for Dart
val bytes = ByteArray(read * 2)
val buf = ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN)
for (i in 0 until read) buf.putShort(chunk[i])
// Dispatch to Dart via EventChannel on main thread
val payload = bytes.copyOf()
mainHandler.post { eventSink?.success(payload) }
}
}
record.stop()
record.release()
}, "AudioCapture")
captureThread!!.start()
}
private fun stopCapture() {
capturing.set(false)
captureThread?.join(500)
captureThread = null
audioRecord = null
}
// ── Playback ─────────────────────────────────────────────────────
/**
* Start the AudioTrack in streaming mode.
* Dart pushes PCM chunks via writePlayback().
*/
private fun startPlayback(sampleRate: Int) {
if (playing.get()) return
val bufBytes = PLAYBACK_BUFFER_SAMPLES * 2
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setBufferSizeInBytes(bufBytes)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
track.play()
audioTrack = track
playing.set(true)
Log.i(TAG, "AudioTrack started, sampleRate=$sampleRate")
}
/**
* Write Int16 LE PCM bytes to the AudioTrack buffer.
* Called from Dart whenever the FSK modulator or LPC decoder produces output.
*/
private fun writePlayback(bytes: ByteArray) {
val track = audioTrack ?: return
if (!playing.get()) return
// Write non-blocking; discard if buffer full (transient)
track.write(bytes, 0, bytes.size, AudioTrack.WRITE_NON_BLOCKING)
}
private fun stopPlayback() {
playing.set(false)
audioTrack?.stop()
audioTrack?.release()
audioTrack = null
}
// ── Routing ──────────────────────────────────────────────────────
/**
* Switch between speakerphone (loudspeaker mode) and earpiece.
*
* FSK acoustic coupling requires SPEAKER mode so the phone's bottom mic
* can pick up audio coming from the speaker. When receiving decoded voice,
* you may optionally switch to earpiece for privacy.
*/
private fun setSpeakerMode(enabled: Boolean) {
val am = appContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = enabled
Log.i(TAG, "Speaker mode: $enabled")
}
private fun getAudioRouteInfo(): Map<String, Any> {
val am = appContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
return mapOf(
"speakerOn" to am.isSpeakerphoneOn,
"mode" to am.mode,
"sampleRate" to SAMPLE_RATE,
"chunkSize" to CAPTURE_CHUNK_SAMPLES
)
}
}

View File

@ -0,0 +1,12 @@
package com.example.call
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Register the AudioEngine method channel and event channel.
AudioEngine.register(this, flutterEngine.dartExecutor.binaryMessenger)
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
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
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
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
}
include(":app")

34
call/ios/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1,620 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.call;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.call.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.call.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.call.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.call;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.call;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Some files were not shown because too many files have changed in this diff Show More