932 lines
34 KiB
Dart
932 lines
34 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui';
|
|
import 'package:another_telephony/telephony.dart';
|
|
import '../utils/app_theme.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
|
|
import '../utils/contact_helper.dart';
|
|
import '../utils/database_helper.dart';
|
|
import '../utils/protocol_helper.dart';
|
|
import '../utils/secure_crypto_helper.dart';
|
|
import '../utils/secure_messaging_service.dart';
|
|
import '../models/chat_model.dart';
|
|
import '../widgets/message_bubble.dart';
|
|
|
|
class MeshBackgroundPainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()..maskFilter = const MaskFilter.blur(BlurStyle.normal, 80);
|
|
|
|
// Warm accent for groups
|
|
paint.color = const Color(0xFFFFF3E0).withValues(alpha: 0.35);
|
|
canvas.drawCircle(Offset(size.width * 0.8, size.height * 0.1), 160, paint);
|
|
|
|
paint.color = const Color(0xFFF3E5F5).withValues(alpha: 0.35);
|
|
canvas.drawCircle(Offset(size.width * 0.2, size.height * 0.8), 200, paint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|
|
|
|
class GroupMessageModel {
|
|
int? id;
|
|
String body;
|
|
String? rawBody;
|
|
int date;
|
|
bool isMe;
|
|
MessageStatus status;
|
|
String? senderPhone;
|
|
String? senderName;
|
|
bool isSecure;
|
|
bool canRetryDecryption;
|
|
String? packetId;
|
|
String? packetMode;
|
|
bool isPendingMultipart;
|
|
|
|
GroupMessageModel({
|
|
this.id,
|
|
required this.body,
|
|
this.rawBody,
|
|
required this.date,
|
|
required this.isMe,
|
|
required this.status,
|
|
this.senderPhone,
|
|
this.senderName,
|
|
this.isSecure = false,
|
|
this.canRetryDecryption = false,
|
|
this.packetId,
|
|
this.packetMode,
|
|
this.isPendingMultipart = false,
|
|
});
|
|
}
|
|
|
|
class GroupChatScreen extends StatefulWidget {
|
|
final int groupId;
|
|
final String groupName;
|
|
|
|
const GroupChatScreen({
|
|
super.key,
|
|
required this.groupId,
|
|
required this.groupName,
|
|
});
|
|
|
|
@override
|
|
State<GroupChatScreen> createState() => _GroupChatScreenState();
|
|
}
|
|
|
|
class _GroupChatScreenState extends State<GroupChatScreen> {
|
|
final Telephony telephony = Telephony.instance;
|
|
static const platform = MethodChannel('com.example.saba/sim_cards');
|
|
|
|
final TextEditingController _msgController = TextEditingController();
|
|
final TextEditingController _keyController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
Color get primaryColor => Theme.of(context).primaryColor;
|
|
final Color backgroundColor = const Color(0xFFF5F7FA);
|
|
|
|
List<GroupMessageModel> messages = [];
|
|
List<Map<String, dynamic>> members = [];
|
|
List<Map<String, dynamic>> _simCards = [];
|
|
Map<String, dynamic>? _selectedSim;
|
|
StreamSubscription? _msgSub;
|
|
|
|
bool _loadingSims = true;
|
|
String currentGroupName = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
currentGroupName = widget.groupName;
|
|
SecureMessagingService.instance.currentChatPhone = 'group_${widget.groupId}';
|
|
DatabaseHelper.instance.markGroupAsRead(widget.groupId);
|
|
_fetchSimCards();
|
|
_loadGroupKeyAndData();
|
|
_scrollController.addListener(_onScroll);
|
|
_msgSub = SecureMessagingService.instance.messageStream.listen((data) {
|
|
if (data['phone'] == 'group_${widget.groupId}') {
|
|
if (!mounted) return;
|
|
final body = data['body'] as String? ?? '';
|
|
final packetId = data['packetId'] as String?;
|
|
final isMultipartComplete = data['isMultipartComplete'] as bool? ?? false;
|
|
final isMeEvent = data['isMe'] as bool? ?? false;
|
|
|
|
if (body == 'REFRESH') {
|
|
_loadData();
|
|
return;
|
|
}
|
|
|
|
if (packetId != null) {
|
|
setState(() {
|
|
final idx = messages.indexWhere((m) => m.packetId == packetId);
|
|
if (idx != -1) {
|
|
messages[idx].body = body;
|
|
messages[idx].isPendingMultipart = !isMultipartComplete;
|
|
if (isMultipartComplete) {
|
|
messages[idx].status = isMeEvent ? MessageStatus.sent : MessageStatus.received;
|
|
}
|
|
} else {
|
|
// If it's a new fragmented message or just completed but not in list
|
|
_loadData();
|
|
}
|
|
});
|
|
} else {
|
|
_loadData();
|
|
}
|
|
}
|
|
});
|
|
_keyController.addListener(() {
|
|
if (mounted) _onKeyChanged();
|
|
});
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients) return;
|
|
// Group chat might need pagination in the future,
|
|
// for now we just monitor the state to prevent jumps.
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (SecureMessagingService.instance.currentChatPhone == 'group_${widget.groupId}') {
|
|
SecureMessagingService.instance.currentChatPhone = null;
|
|
}
|
|
_msgSub?.cancel();
|
|
_msgController.dispose();
|
|
_keyController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadGroupKeyAndData() async {
|
|
final groups = await DatabaseHelper.instance.getGroups();
|
|
final group = groups.cast<Map<String, dynamic>?>().firstWhere(
|
|
(g) => g?['id'] == widget.groupId,
|
|
orElse: () => null,
|
|
);
|
|
if (group != null && (group['group_key'] as String?)?.isNotEmpty == true) {
|
|
_keyController.text = group['group_key'] as String;
|
|
}
|
|
await _loadData();
|
|
}
|
|
|
|
void _onKeyChanged() {
|
|
DatabaseHelper.instance.updateGroupKey(widget.groupId, _keyController.text);
|
|
_loadData();
|
|
}
|
|
|
|
bool _isSecurePlaceholderBody(String body) {
|
|
return body.startsWith('پیام امن گروهی') ||
|
|
body.startsWith('بازگشایی پیام امن گروهی') ||
|
|
body.contains(' ::PAYLOAD::');
|
|
}
|
|
|
|
String? _extractPayload(String body) {
|
|
const separator = ' ::PAYLOAD::';
|
|
final splitIndex = body.lastIndexOf(separator);
|
|
if (splitIndex == -1) return null;
|
|
final payload = body.substring(splitIndex + separator.length).trim();
|
|
return payload.isEmpty ? null : payload;
|
|
}
|
|
|
|
Future<void> _handleRetryDecryption(GroupMessageModel msg) async {
|
|
String? payload = _extractPayload(msg.body);
|
|
if (payload == null && msg.rawBody != null) {
|
|
payload = ProtocolHelper.parseMessage(msg.rawBody!)['payload'] as String?;
|
|
}
|
|
if (payload == null) return;
|
|
|
|
final keyController = TextEditingController(text: _keyController.text.trim());
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
title: const Text('بازگشایی دستی پیام گروه'),
|
|
content: TextField(
|
|
controller: keyController,
|
|
decoration: const InputDecoration(
|
|
hintText: 'کلید گروه',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('لغو')),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
if (keyController.text.trim().isEmpty) return;
|
|
final decrypted = await SecureMessagingService.instance.decryptWithKey(
|
|
payload!,
|
|
keyController.text.trim(),
|
|
);
|
|
if (!mounted) return;
|
|
Navigator.pop(ctx);
|
|
if (decrypted != null) {
|
|
_keyController.text = keyController.text.trim();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('پیام گروهی بازگشایی شد.')),
|
|
);
|
|
_loadData();
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('کلید گروه نادرست است.')),
|
|
);
|
|
}
|
|
},
|
|
child: const Text('بازگشایی'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _resolveGroupMessageState(String rawBody,
|
|
{bool isMe = false, bool dbIsSecure = false}) async {
|
|
final parsed = ProtocolHelper.parseMessage(rawBody);
|
|
final type = parsed['type'] as String;
|
|
final key = _keyController.text.trim();
|
|
|
|
String body = rawBody;
|
|
bool isSecure = dbIsSecure || type != 'plain' || rawBody.contains(' ::PAYLOAD::');
|
|
bool canRetryDecryption = false;
|
|
|
|
if (type == 'sym' && parsed['isGroup'] == true) {
|
|
final payload = parsed['payload'] as String?;
|
|
if (payload != null && key.isNotEmpty) {
|
|
final decrypted = await SecureMessagingService.instance.decryptWithKey(payload, key);
|
|
if (decrypted != null) {
|
|
body = decrypted;
|
|
} else {
|
|
final label = isMe ? 'ارسال شده' : 'دریافت شد';
|
|
body = 'بازگشایی پیام امن گروهی $label ناموفق بود. کلید گروه را بررسی کنید. ::PAYLOAD::$payload';
|
|
canRetryDecryption = true;
|
|
}
|
|
} else if (payload != null) {
|
|
final label = isMe ? 'ارسال شده' : 'دریافت شد';
|
|
body = 'پیام امن گروهی $label. برای مشاهده، کلید گروه را وارد کنید. ::PAYLOAD::$payload';
|
|
canRetryDecryption = true;
|
|
}
|
|
} else if (type == 'sfra' && parsed['isGroup'] == true) {
|
|
body = 'در حال دریافت قطعات... (${parsed['partNo']}/${parsed['totalParts']})';
|
|
isSecure = true;
|
|
} else if (rawBody.contains(' ::PAYLOAD::')) {
|
|
canRetryDecryption = true;
|
|
}
|
|
|
|
return {
|
|
'body': body,
|
|
'isSecure': isSecure,
|
|
'canRetryDecryption': canRetryDecryption,
|
|
'packetId': parsed['packetId'],
|
|
'packetMode': type == 'plain' ? null : (type == 'afrag' ? 'AE' : 'SYM'),
|
|
'isPendingMultipart': type == 'sfra' || type == 'afrag',
|
|
};
|
|
}
|
|
|
|
Future<void> _fetchSimCards() async {
|
|
if (!await Permission.phone.request().isGranted) {
|
|
if (mounted) setState(() => _loadingSims = false);
|
|
return;
|
|
}
|
|
try {
|
|
final List<dynamic> result = await platform.invokeMethod('getSimCards');
|
|
final cleanList = result.map((e) {
|
|
final rawMap = e as Map<Object?, Object?>;
|
|
return rawMap.map((key, value) => MapEntry(key.toString(), value));
|
|
}).toList();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_simCards = cleanList;
|
|
if (_simCards.isNotEmpty) _selectedSim = _simCards.first;
|
|
_loadingSims = false;
|
|
});
|
|
} catch (_) {
|
|
if (mounted) setState(() => _loadingSims = false);
|
|
}
|
|
}
|
|
|
|
bool _isLoading = false;
|
|
bool _needsReload = false;
|
|
|
|
Future<void> _loadData() async {
|
|
if (_isLoading) {
|
|
_needsReload = true;
|
|
return;
|
|
}
|
|
_isLoading = true;
|
|
_needsReload = false;
|
|
|
|
try {
|
|
final dbMessages = await DatabaseHelper.instance.getGroupMessages(widget.groupId);
|
|
final dbMembers = await DatabaseHelper.instance.getGroupMembers(widget.groupId);
|
|
|
|
final uiMessages = <GroupMessageModel>[];
|
|
for (final m in dbMessages) {
|
|
final raw = m['body'] as String;
|
|
final senderPhone = m['sender_phone'] as String?;
|
|
final senderName = senderPhone != null ? ContactHelper.getName(senderPhone) : 'من';
|
|
final dbIsSecure = (m['is_secure'] as int? ?? 0) == 1;
|
|
final resolved = await _resolveGroupMessageState(raw, isMe: senderPhone == null, dbIsSecure: dbIsSecure);
|
|
|
|
uiMessages.add(GroupMessageModel(
|
|
id: m['id'] as int?,
|
|
body: resolved['body'] as String,
|
|
rawBody: raw,
|
|
date: m['date'] as int,
|
|
isMe: senderPhone == null,
|
|
status: MessageStatus.received,
|
|
senderPhone: senderPhone,
|
|
senderName: senderName,
|
|
isSecure: resolved['isSecure'] as bool,
|
|
canRetryDecryption:
|
|
resolved['canRetryDecryption'] as bool || _isSecurePlaceholderBody(resolved['body'] as String),
|
|
packetId: resolved['packetId'] as String?,
|
|
packetMode: resolved['packetMode'] as String?,
|
|
isPendingMultipart: resolved['isPendingMultipart'] as bool? ?? false,
|
|
));
|
|
}
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
messages = uiMessages;
|
|
members = dbMembers;
|
|
});
|
|
}
|
|
} finally {
|
|
_isLoading = false;
|
|
if (_needsReload) _loadData();
|
|
}
|
|
}
|
|
|
|
void _deleteMessage(int messageId) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
title: const Text('حذف پیام'),
|
|
content: const Text('آیا مطمئن هستید؟'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('لغو')),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
|
|
onPressed: () async {
|
|
Navigator.pop(ctx);
|
|
await DatabaseHelper.instance.deleteGroupMessage(messageId);
|
|
_loadData();
|
|
},
|
|
child: const Text('حذف'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _editGroupName() {
|
|
final nameController = TextEditingController(text: currentGroupName);
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
title: const Text('تغییر نام گروه'),
|
|
content: TextField(
|
|
controller: nameController,
|
|
decoration: InputDecoration(
|
|
labelText: 'نام جدید',
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('لغو')),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: primaryColor, foregroundColor: Colors.white),
|
|
onPressed: () async {
|
|
if (nameController.text.trim().isEmpty) return;
|
|
await DatabaseHelper.instance.updateGroupName(widget.groupId, nameController.text.trim());
|
|
if (!mounted) return;
|
|
setState(() => currentGroupName = nameController.text.trim());
|
|
Navigator.pop(ctx);
|
|
},
|
|
child: const Text('ذخیره'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showGroupInfo() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
return StatefulBuilder(
|
|
builder: (context, setStateDialog) {
|
|
return AlertDialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
title: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('مدیریت اعضا', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
IconButton(
|
|
icon: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(
|
|
color: primaryColor.withValues(alpha: 0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(Icons.person_add, color: primaryColor, size: 20),
|
|
),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_addNewMember();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: members.isEmpty
|
|
? const Center(child: Text('هیچ عضوی وجود ندارد', style: TextStyle(color: Colors.grey)))
|
|
: ListView.separated(
|
|
shrinkWrap: true,
|
|
itemCount: members.length,
|
|
separatorBuilder: (_, __) => const Divider(),
|
|
itemBuilder: (_, index) {
|
|
final member = members[index];
|
|
return ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: CircleAvatar(
|
|
backgroundColor: Colors.orange.shade100,
|
|
child: Text(
|
|
(member['name'] as String)[0],
|
|
style: TextStyle(color: Colors.orange.shade900),
|
|
),
|
|
),
|
|
title: Text(
|
|
member['name'] as String,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: Text(member['phone'] as String),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
|
onPressed: () async {
|
|
await DatabaseHelper.instance.removeGroupMember(
|
|
widget.groupId,
|
|
member['phone'] as String,
|
|
);
|
|
final newMembers = await DatabaseHelper.instance.getGroupMembers(widget.groupId);
|
|
if (!mounted) return;
|
|
setState(() => members = newMembers);
|
|
setStateDialog(() {});
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text('بستن')),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _addNewMember() async {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
|
);
|
|
final contacts = await ContactHelper.getContactsLight();
|
|
if (mounted) Navigator.pop(context);
|
|
|
|
if (contacts.isEmpty) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('مخاطبی یافت نشد')));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
final result = await showSearch<Map<String, String>?>(
|
|
context: context,
|
|
delegate: ContactsSearchDelegate(contacts),
|
|
);
|
|
|
|
if (result == null) return;
|
|
final added = await DatabaseHelper.instance.addMemberToGroup(
|
|
widget.groupId,
|
|
result['name']!,
|
|
result['phone']!,
|
|
);
|
|
if (!mounted) return;
|
|
|
|
if (added) {
|
|
await _loadData();
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('عضو جدید اضافه شد')));
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('این عضو قبلاً در گروه وجود دارد')),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _sendGroupMessage() async {
|
|
final text = _msgController.text.trim();
|
|
final key = _keyController.text.trim();
|
|
if (text.isEmpty) return;
|
|
|
|
String? rawBody;
|
|
if (key.isNotEmpty) {
|
|
final crypto = SecureCryptoHelper();
|
|
rawBody = '@G:SYM|${await crypto.encryptSymmetric(text, key)}';
|
|
}
|
|
|
|
final tempMsg = GroupMessageModel(
|
|
body: text,
|
|
rawBody: rawBody,
|
|
date: DateTime.now().millisecondsSinceEpoch,
|
|
isMe: true,
|
|
status: MessageStatus.sending,
|
|
senderName: 'من',
|
|
isSecure: key.isNotEmpty,
|
|
);
|
|
|
|
setState(() {
|
|
messages.insert(0, tempMsg);
|
|
_msgController.clear();
|
|
});
|
|
|
|
try {
|
|
final groupMemberPhones = members.map((m) => m['phone'] as String).toList();
|
|
await SecureMessagingService.instance.sendGroupSecureMessage(
|
|
widget.groupId,
|
|
groupMemberPhones,
|
|
text,
|
|
key,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
setState(() => tempMsg.status = MessageStatus.sent);
|
|
Future.delayed(const Duration(milliseconds: 1200), _loadData);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() => tempMsg.status = MessageStatus.failed);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppTheme.darkBg,
|
|
extendBodyBehindAppBar: true,
|
|
resizeToAvoidBottomInset: true,
|
|
appBar: PreferredSize(
|
|
preferredSize: const Size.fromHeight(kToolbarHeight + 8),
|
|
child: AppTheme.glassWrapper(
|
|
radius: 0,
|
|
sigma: 18,
|
|
child: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
title: InkWell(
|
|
onTap: _editGroupName,
|
|
overlayColor: WidgetStateProperty.all(Colors.transparent),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Hero(
|
|
tag: 'avatar_${currentGroupName}_group',
|
|
child: Container(
|
|
width: 38,
|
|
height: 38,
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
colors: [Colors.orange, Colors.orangeAccent],
|
|
),
|
|
),
|
|
child: const Center(
|
|
child: Icon(Icons.groups, color: Colors.white, size: 22),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(currentGroupName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
|
Text('${members.length} عضو', style: const TextStyle(fontSize: 11, color: Colors.white70)),
|
|
],
|
|
),
|
|
const SizedBox(width: 6),
|
|
const Icon(Icons.edit_outlined, size: 14, color: Colors.white70),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.group_outlined, color: Colors.white),
|
|
tooltip: 'مدیریت اعضا',
|
|
onPressed: _showGroupInfo,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
Positioned.fill(child: CustomPaint(painter: MeshBackgroundPainter())),
|
|
Column(
|
|
children: [
|
|
SizedBox(height: MediaQuery.of(context).padding.top + kToolbarHeight + 8),
|
|
Expanded(
|
|
child: messages.isEmpty
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.mark_chat_unread_outlined, size: 60, color: Colors.grey[300]),
|
|
const SizedBox(height: 10),
|
|
Text('پیامی ارسال نشده است', style: TextStyle(color: Colors.grey[500])),
|
|
],
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
key: const PageStorageKey('group_chat_list'),
|
|
controller: _scrollController,
|
|
reverse: true,
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 20),
|
|
itemCount: messages.length,
|
|
itemBuilder: (context, index) {
|
|
final msg = messages[index];
|
|
return GestureDetector(
|
|
onLongPress: () {
|
|
if (msg.id != null) {
|
|
_deleteMessage(msg.id!);
|
|
}
|
|
},
|
|
child: Column(
|
|
crossAxisAlignment: msg.isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
children: [
|
|
if (!msg.isMe && msg.senderName != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 12, bottom: 4),
|
|
child: Text(
|
|
msg.senderName!,
|
|
style: const TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.indigo,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
MessageBubble(
|
|
body: msg.body,
|
|
rawBody: msg.rawBody,
|
|
date: msg.date,
|
|
isMe: msg.isMe,
|
|
status: msg.status,
|
|
isSecure: msg.isSecure,
|
|
canRetryDecryption: msg.canRetryDecryption,
|
|
onRetryDecryption: () => _handleRetryDecryption(msg),
|
|
packetMode: msg.packetMode,
|
|
isPendingMultipart: msg.isPendingMultipart,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
AppTheme.glassWrapper(
|
|
radius: 0,
|
|
sigma: 15,
|
|
child: Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.03),
|
|
border: Border(
|
|
top: BorderSide(color: Colors.white.withValues(alpha: 0.08), width: 0.5),
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (!_loadingSims && _simCards.length > 1)
|
|
SizedOverflowBox(
|
|
size: const Size.fromHeight(35),
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: _simCards.length,
|
|
itemBuilder: (context, index) {
|
|
final sim = _simCards[index];
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: ChoiceChip(
|
|
label: Text(sim['displayName']?.toString() ?? 'SIM ${index + 1}'),
|
|
selected: _selectedSim == sim,
|
|
onSelected: (_) => setState(() => _selectedSim = sim),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _keyController,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
decoration: InputDecoration(
|
|
hintText: 'کلید امنیتی گروه (اختیاری)',
|
|
hintStyle: const TextStyle(color: Colors.white30),
|
|
prefixIcon: const Icon(Icons.vpn_key_rounded, size: 18, color: Colors.orangeAccent),
|
|
filled: true,
|
|
fillColor: Colors.white.withValues(alpha: 0.05),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _msgController,
|
|
minLines: 1,
|
|
maxLines: 4,
|
|
style: const TextStyle(color: Colors.white),
|
|
decoration: InputDecoration(
|
|
hintText: 'پیام گروهی...',
|
|
hintStyle: const TextStyle(color: Colors.white30),
|
|
filled: true,
|
|
fillColor: Colors.white.withValues(alpha: 0.05),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Container(
|
|
height: 48,
|
|
width: 48,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: const LinearGradient(
|
|
colors: [Colors.indigo, Colors.indigoAccent],
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.indigo.withValues(alpha: 0.3),
|
|
blurRadius: 12,
|
|
spreadRadius: 1,
|
|
),
|
|
],
|
|
),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.send_rounded, color: Colors.white, size: 22),
|
|
onPressed: _sendGroupMessage,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ContactsSearchDelegate extends SearchDelegate<Map<String, String>?> {
|
|
final List<Contact> contacts;
|
|
|
|
ContactsSearchDelegate(this.contacts);
|
|
|
|
@override
|
|
String? get searchFieldLabel => 'جستوجو نام مخاطب...';
|
|
|
|
@override
|
|
List<Widget>? buildActions(BuildContext context) {
|
|
return [
|
|
if (query.isNotEmpty)
|
|
IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () => query = '',
|
|
),
|
|
];
|
|
}
|
|
|
|
@override
|
|
Widget? buildLeading(BuildContext context) {
|
|
return IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => close(context, null),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget buildResults(BuildContext context) => _buildList(context);
|
|
|
|
@override
|
|
Widget buildSuggestions(BuildContext context) => _buildList(context);
|
|
|
|
Widget _buildList(BuildContext context) {
|
|
final filtered = query.isEmpty
|
|
? contacts
|
|
: contacts.where((c) => c.displayName.toLowerCase().contains(query.toLowerCase())).toList();
|
|
|
|
if (filtered.isEmpty) {
|
|
return const Center(child: Text('نتیجهای یافت نشد.'));
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: filtered.length,
|
|
itemBuilder: (context, index) {
|
|
final contact = filtered[index];
|
|
return ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: Colors.indigo.shade100,
|
|
child: Text(
|
|
contact.displayName.isNotEmpty ? contact.displayName[0] : '?',
|
|
style: TextStyle(color: Colors.indigo.shade900),
|
|
),
|
|
),
|
|
title: Text(contact.displayName),
|
|
onTap: () => _onContactSelect(context, contact.id),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _onContactSelect(BuildContext context, String contactId) async {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (_) => const Center(child: CircularProgressIndicator()),
|
|
);
|
|
try {
|
|
final fullContact = await ContactHelper.getFullContact(contactId);
|
|
if (context.mounted) Navigator.pop(context);
|
|
if (fullContact == null || fullContact.phones.isEmpty) return;
|
|
|
|
if (fullContact.phones.length == 1) {
|
|
close(context, {
|
|
'name': fullContact.displayName,
|
|
'phone': fullContact.phones.first.number,
|
|
});
|
|
} else {
|
|
_showPhoneSelection(context, fullContact);
|
|
}
|
|
} catch (_) {
|
|
if (context.mounted) Navigator.pop(context);
|
|
}
|
|
}
|
|
|
|
void _showPhoneSelection(BuildContext context, Contact contact) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (_) => SimpleDialog(
|
|
title: Text(contact.displayName),
|
|
children: contact.phones
|
|
.map(
|
|
(p) => SimpleDialogOption(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
close(context, {
|
|
'name': contact.displayName,
|
|
'phone': p.number,
|
|
});
|
|
},
|
|
child: Text(p.number),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|