refactor admin/add slpash in front

This commit is contained in:
wikm 2026-03-16 16:51:53 +03:30
parent aaad523538
commit 63b6c8e6d3
190 changed files with 10582 additions and 505 deletions

View File

@ -22,6 +22,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

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

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

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'screens/channel_list_screen.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'services/auth_service.dart';
@ -42,33 +42,86 @@ class _Splash extends StatefulWidget {
}
class _SplashState extends State<_Splash> {
@override
void initState() {
super.initState();
_route();
}
bool _loading = false;
Future<void> _route() async {
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(),
builder: (_) => loggedIn ? const HomeScreen() : const LoginScreen(),
),
);
}
@override
Widget build(BuildContext context) {
return const Scaffold(
return GestureDetector(
onTap: _onTap,
child: Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(10),
child: Image.asset(
'assets/images/logo.png',
fit: BoxFit.contain,
),
),
const SizedBox(height: 14),
const Text(
'مرکز هوش مصنوعی',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
letterSpacing: 0.4,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 3),
const Text(
'و فناوری‌های نو ظهور سپاه',
style: TextStyle(
color: Color(0xFF00C853),
fontSize: 10,
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
const Text(
'ضربه بزنید',
style: TextStyle(color: 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

@ -142,42 +142,101 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
body: SafeArea(
child: Column(
children: [
// Header
// Header centered for watch face
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(width: 4),
const Icon(Icons.settings_input_antenna,
color: Color(0xFF00C853), size: 16),
const SizedBox(width: 4),
const Expanded(
child: Text(
color: Color(0xFF00C853), size: 14),
const SizedBox(width: 6),
const Text(
'کانال‌ها',
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold),
),
),
// Notifications icon with badge
const SizedBox(width: 10),
// Menu button uses child: to avoid M3 size inflation
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
onPressed: _openNotifications,
PopupMenuButton<String>(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
icon: const Icon(Icons.notifications_outlined,
color: Colors.white54, size: 18),
child: const Icon(Icons.menu,
color: Colors.white, size: 22),
onSelected: (value) {
switch (value) {
case 'notifications':
_openNotifications();
case 'create':
_showCreateGroupDialog();
case 'refresh':
if (!_loading) _refresh();
case 'logout':
_logout();
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'notifications',
child: Row(
children: [
const Icon(Icons.notifications_outlined,
size: 16),
const SizedBox(width: 8),
Text(_pendingNotifCount > 0
? 'اعلان‌ها ($_pendingNotifCount)'
: 'اعلان‌ها'),
],
),
),
const PopupMenuItem(
value: 'create',
child: Row(
children: [
Icon(Icons.add_circle_outline,
size: 16, color: Color(0xFF00C853)),
SizedBox(width: 8),
Text('گروه جدید'),
],
),
),
PopupMenuItem(
value: 'refresh',
enabled: !_loading,
child: const Row(
children: [
Icon(Icons.refresh, size: 16),
SizedBox(width: 8),
Text('بروزرسانی'),
],
),
),
const PopupMenuItem(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout,
size: 16, color: Colors.redAccent),
SizedBox(width: 8),
Text('خروج',
style:
TextStyle(color: Colors.redAccent)),
],
),
),
],
),
if (_pendingNotifCount > 0)
Positioned(
top: 4,
right: 4,
top: -2,
right: -2,
child: Container(
width: 8,
height: 8,
width: 7,
height: 7,
decoration: const BoxDecoration(
color: Color(0xFFFF1744),
shape: BoxShape.circle,
@ -186,32 +245,6 @@ class _ChannelListScreenState extends State<ChannelListScreen> {
),
],
),
// 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),
),
],
),
),

View File

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'channel_list_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: 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,7 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../services/api_service.dart';
import 'channel_list_screen.dart';
import 'home_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@ -49,7 +49,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (ok) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ChannelListScreen()),
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
} else {
setState(() {

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('خط اشغال است');
@ -171,22 +172,24 @@ class PttService {
String token, {
bool setConnectedState = true,
}) async {
try {
try {
await _listener?.dispose();
await _room?.disconnect();
} 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 (_) {}
try {
await _listener?.dispose();
await _room?.disconnect();
} catch (_) {}
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/

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(
_ActionCard(
label: 'ارسال اعلان عمومی',
icon: Icons.campaign_rounded,
color: const Color(0xFFEA580C),
onTap: () => context.go('/notifications'),
),
]
.map((w) => SizedBox(
width: isWide
? (constraints.maxWidth - 36) / 4
? (constraints.maxWidth - 48) / 5
: (constraints.maxWidth - 12) / 2,
child: w,
)).toList(),
))
.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(),
),
),
),
@ -348,35 +328,12 @@ class _GroupsTable extends StatelessWidget {
),
),
DataCell(
SizedBox(
width: 180,
child: Text(
group.description ?? '',
Text(
group.type.label,
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!)
: '',
style: const TextStyle(color: AppTheme.textSecondary),
),
),
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(
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

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