Saba-dart/lib/screens/compose_screen.dart
2026-04-13 23:41:27 +03:30

774 lines
31 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:another_telephony/telephony.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import '../utils/secure_messaging_service.dart';
import '../utils/database_helper.dart';
import '../utils/contact_helper.dart';
import '../utils/secure_crypto_helper.dart';
import '../utils/app_theme.dart';
import 'chat_screen.dart';
class ComposeScreen extends StatefulWidget {
const ComposeScreen({super.key});
@override
State<ComposeScreen> createState() => _ComposeScreenState();
}
class _ComposeScreenState extends State<ComposeScreen> {
final Telephony telephony = Telephony.instance;
static const platform = MethodChannel('com.example.saba/sim_cards');
static const asymmetricModeLabel = 'رمزنگاری غیر متقارن (طولانی‌تر و امن‌تر)';
final _phoneController = TextEditingController();
final _msgController = TextEditingController();
final _keyController = TextEditingController();
final _groupNameController = TextEditingController();
final List<Map<String, String>> _selectedContacts = [];
// --- کش داخلی (لیست سبک مخاطبین) ---
// به جای Map، از کلاس Contact استفاده می‌کنیم که فقط ID و Name دارد
List<Contact> _contactsCache = [];
List<Map<String, dynamic>> _simCards = [];
Map<String, dynamic>? _selectedSim;
bool _loadingSims = true;
bool isSending = false;
String _selectedSecurityLevel = 'normal';
Color get primaryColor => Theme.of(context).primaryColor;
final Color backgroundColor = const Color(0xFFF5F7FA);
@override
void initState() {
super.initState();
_fetchSimCards();
// لود کردن لیست سبک در شروع
_loadLightContacts();
}
// دریافت لیست سبک (نام و آیدی) - بسیار سریع
Future<void> _loadLightContacts() async {
try {
final contacts = await ContactHelper.getContactsLight();
if (mounted) {
setState(() {
_contactsCache = contacts;
});
}
} catch (e) {
print("Error loading light contacts: $e");
}
}
// دکمه آپدیت: هم لیست جستجو را آپدیت می‌کند، هم دیتابیس را
Future<void> _forceSyncContacts({bool silent = false}) async {
if (!silent) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
}
// 1. آپدیت لیست جستجو (سریع)
await _loadLightContacts();
// 2. آپدیت دیتابیس برای نمایش نام در صفحه اصلی (سنگین)
await ContactHelper.syncWithDevice();
if (!silent && mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("✅ مخاطبین بروزرسانی شدند")));
}
}
Future<void> _fetchSimCards() async {
if (!await Permission.phone.request().isGranted) {
setState(() => _loadingSims = false);
return;
}
try {
final List<dynamic> result = await platform.invokeMethod('getSimCards');
List<Map<String, dynamic>> cleanList = result.map((e) {
final Map<Object?, Object?> rawMap = e as Map<Object?, Object?>;
return rawMap.map((key, value) => MapEntry(key.toString(), value));
}).toList();
setState(() {
_simCards = cleanList;
if (_simCards.isNotEmpty) _selectedSim = _simCards[0];
_loadingSims = false;
});
} catch (e) {
setState(() => _loadingSims = false);
}
}
void _openContactSearch() async {
if (!await FlutterContacts.requestPermission(readonly: true)) return;
// اگر لیست خالی بود، سعی کن دوباره بگیری
if (_contactsCache.isEmpty) {
await _loadLightContacts();
}
if (_contactsCache.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text("مخاطبی یافت نشد")));
}
return;
}
// باز کردن سرچ با لیست سبک
final result = await showSearch<Map<String, String>?>(
context: context,
delegate: ContactsSearchDelegate(_contactsCache),
);
if (result != null) {
setState(() {
// نرمال‌سازی شماره قبل از افزودن
final normalizedPhone = ContactHelper.normalizePhone(result['phone']!);
final exists = _selectedContacts.any((c) =>
ContactHelper.normalizePhone(c['phone']!) == normalizedPhone);
if (!exists) {
result['phone'] = normalizedPhone;
_selectedContacts.add(result);
}
if (_selectedContacts.length == 1) {
_phoneController.text = _selectedContacts[0]['phone']!;
} else {
_phoneController.clear();
}
});
}
}
void _removeContact(int index) {
setState(() {
_selectedContacts.removeAt(index);
if (_selectedContacts.length == 1) {
_phoneController.text = _selectedContacts[0]['phone']!;
} else if (_selectedContacts.isEmpty) {
_phoneController.clear();
}
});
}
String _securityLabel(String level) {
switch (level) {
case 'symmetric':
return 'رمزنگاری متقارن';
case 'asymmetric':
return asymmetricModeLabel;
default:
return 'ارسال عادی';
}
}
Widget _buildSecurityModeCard() {
const options = <String>['normal', 'symmetric', 'asymmetric'];
return AppTheme.glassWrapper(
radius: 18,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'سطح امنیت',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: options.map((level) {
return ChoiceChip(
label: Text(_securityLabel(level)),
selected: _selectedSecurityLevel == level,
onSelected: (_) {
setState(() {
_selectedSecurityLevel = level;
});
},
selectedColor: primaryColor,
backgroundColor: Colors.white.withValues(alpha: 0.05),
labelStyle: TextStyle(
color: _selectedSecurityLevel == level
? Colors.white
: Colors.white70,
fontWeight: FontWeight.w600,
),
);
}).toList(),
),
const SizedBox(height: 12),
Text(
_selectedSecurityLevel == 'asymmetric'
? 'در حالت $asymmetricModeLabel برنامه در صورت نیاز ابتدا تبادل کلید را از طریق پیامک انجام می‌دهد و سپس پیام اصلی را می‌فرستد.'
: _selectedSecurityLevel == 'symmetric'
? 'در این حالت باید کلید مشترک یکسانی در دو طرف وارد شده باشد.'
: 'در این حالت پیام بدون رمزنگاری اضافه ارسال می‌شود.',
style: const TextStyle(
color: Colors.white60,
fontSize: 12,
height: 1.5,
),
),
],
),
),
);
}
Future<void> _sendMessage() async {
bool isGroupMode = _selectedContacts.length > 1;
if (isGroupMode) {
if (_groupNameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("لطفاً نام گروه را وارد کنید")));
return;
}
if (_msgController.text.isEmpty) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text("متن پیام خالی است")));
return;
}
int groupId = await DatabaseHelper.instance
.createGroup(_groupNameController.text, _selectedContacts);
String finalMsg = _msgController.text;
if (_keyController.text.isNotEmpty) {
final crypto = SecureCryptoHelper();
finalMsg = await crypto.encryptSymmetric(_msgController.text, _keyController.text);
finalMsg = "@G:SYM|" + finalMsg; // Prefix for Group Symmetric
}
setState(() => isSending = true);
int? subId =
_selectedSim != null ? _selectedSim!['subscriptionId'] as int : null;
for (var member in _selectedContacts) {
try {
String phone = ContactHelper.normalizePhone(member['phone']!);
if (subId != null) {
await telephony.sendSms(
to: phone, message: finalMsg, subscriptionId: subId);
} else {
await telephony.sendSms(to: phone, message: finalMsg);
}
} catch (_) {}
}
await DatabaseHelper.instance.saveGroupMessage(
groupId, finalMsg, DateTime.now().millisecondsSinceEpoch);
setState(() => isSending = false);
if (mounted) Navigator.pop(context, true);
return;
}
String rawPhone = _phoneController.text;
if (rawPhone.isEmpty && _selectedContacts.isNotEmpty) {
rawPhone = _selectedContacts[0]['phone']!;
}
if (rawPhone.isEmpty || _msgController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("شماره و متن الزامی است")));
return;
}
final normalizedPhone = ContactHelper.normalizePhone(rawPhone);
final messageText = _msgController.text.trim();
final symmetricKey = _keyController.text.trim();
if (_selectedSecurityLevel == 'symmetric' && symmetricKey.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('برای رمزنگاری متقارن، کلید لازم است')),
);
return;
}
setState(() => isSending = true);
try {
final result = await SecureMessagingService.instance.sendMessage(
normalizedPhone,
messageText,
securityLevel: _selectedSecurityLevel,
symmetricKey: _selectedSecurityLevel == 'symmetric' ? symmetricKey : null,
);
if (!mounted) return;
final sentText = result['sentText'] == true;
final notice = result['notice'] as String? ?? '';
final snackText = sentText
? (notice == 'sent_fragmented' ? 'پیام چندبخشی ارسال شد' : 'پیام ارسال شد')
: notice;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(snackText)));
setState(() => isSending = false);
if (sentText) {
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(address: normalizedPhone),
),
result: true,
);
}
});
}
return;
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('خطا: $e')),
);
setState(() => isSending = false);
return;
}
}
@override
Widget build(BuildContext context) {
bool isGroupMode = _selectedContacts.length > 1;
return Scaffold(
backgroundColor: AppTheme.darkBg,
extendBodyBehindAppBar: true,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight + 8),
child: AppTheme.glassWrapper(
radius: 0,
sigma: 18,
child: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: Text(isGroupMode ? "ساخت گروه جدید" : "پیام جدید",
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white)),
foregroundColor: Colors.white,
),
),
),
body: Stack(
children: [
Positioned.fill(child: CustomPaint(painter: MeshBackgroundPainter())),
SingleChildScrollView(
padding: EdgeInsets.fromLTRB(20, MediaQuery.of(context).padding.top + kToolbarHeight + 20, 20, 40),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_loadingSims && _simCards.length > 1)
Container(
margin: const EdgeInsets.only(bottom: 20),
height: 40,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _simCards.length,
itemBuilder: (context, index) {
final sim = _simCards[index];
final isSelected = _selectedSim == sim;
return Padding(
padding: const EdgeInsets.only(right: 10),
child: ChoiceChip(
label: Text("${sim['carrierName']} (Slot ${sim['slotIndex'] + 1})"),
selected: isSelected,
onSelected: (selected) => setState(() => _selectedSim = sim),
selectedColor: primaryColor,
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.white70,
fontSize: 13),
backgroundColor: Colors.white.withValues(alpha: 0.05),
),
);
},
),
),
// --- بخش گیرندگان ---
AppTheme.glassWrapper(
radius: 20,
sigma: 10,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
border: Border.all(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
if (_selectedContacts.isNotEmpty)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _selectedContacts.asMap().entries.map((entry) {
return Chip(
avatar: CircleAvatar(
backgroundColor: primaryColor.withValues(alpha: 0.8),
child: Text(
entry.value['name']!.isNotEmpty ? entry.value['name']![0] : "?",
style: const TextStyle(color: Colors.white, fontSize: 12)),
),
label: Text(entry.value['name']!, style: const TextStyle(color: Colors.white, fontSize: 13)),
deleteIcon: const Icon(Icons.close, size: 16, color: Colors.white70),
onDeleted: () => _removeContact(entry.key),
backgroundColor: Colors.white.withValues(alpha: 0.1),
side: BorderSide.none,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
);
}).toList(),
),
),
if (isGroupMode)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: _groupNameController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: "نام گروه",
labelStyle: const TextStyle(color: Colors.white60),
prefixIcon: const Icon(Icons.groups_rounded, color: Colors.white70),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
),
Row(
children: [
Expanded(
child: TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
enabled: !isGroupMode && _selectedContacts.isEmpty,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: "شماره گیرنده",
labelStyle: const TextStyle(color: Colors.white60),
prefixIcon: const Icon(Icons.phone_iphone_rounded, color: Colors.white70),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
),
const SizedBox(width: 8),
_buildSmallButton(Icons.sync_rounded, Colors.orangeAccent, () => _forceSyncContacts(silent: false)),
const SizedBox(width: 8),
_buildSmallButton(Icons.person_add_rounded, primaryColor, _openContactSearch),
],
),
],
),
),
),
const SizedBox(height: 18),
// --- بخش تنظیمات امنیتی ---
if (!isGroupMode) ...[
_buildSecurityModeCard(),
const SizedBox(height: 18),
],
if (isGroupMode || _selectedSecurityLevel == 'symmetric')
AppTheme.glassWrapper(
radius: 18,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
),
child: TextField(
controller: _keyController,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: isGroupMode ? 'کلید گروه (اختیاری)' : 'کلید رمزنگاری متقارن',
hintStyle: const TextStyle(color: Colors.white30),
icon: const Icon(Icons.vpn_key_rounded, color: Colors.orangeAccent),
border: InputBorder.none,
helperText: isGroupMode
? 'پیام به‌صورت متقارن برای همه اعضا رمز می‌شود.'
: 'کلید باید در هر دو سمت یکسان باشد.',
helperStyle: const TextStyle(color: Colors.white38, fontSize: 11),
),
),
),
),
if (_selectedSecurityLevel == 'asymmetric' && !isGroupMode)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.indigo.withValues(alpha: 0.2)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.shield_rounded, color: Colors.indigoAccent, size: 20),
SizedBox(width: 12),
Expanded(
child: Text(
'در حالت رمزنگاری غیر متقارن، تبادل کلید خودکار انجام می‌شود. پیام‌های طولانی به‌صورت چندبخشی ارسال می‌شوند.',
style: TextStyle(color: Colors.white70, fontSize: 12, height: 1.5),
),
),
],
),
),
const SizedBox(height: 18),
// --- بخش متن پیام ---
AppTheme.glassWrapper(
radius: 20,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.03),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
),
child: TextField(
controller: _msgController,
maxLength: 600,
maxLines: 6,
minLines: 4,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: "متن پیام خود را بنویسید...",
hintStyle: TextStyle(color: Colors.white30),
border: InputBorder.none,
counterStyle: TextStyle(color: Colors.white38),
helperText: "استفاده از پروتکل چندبخشی در صورت نیاز.",
helperStyle: TextStyle(color: Colors.white38, fontSize: 11),
),
),
),
),
const SizedBox(height: 32),
// --- دکمه ارسال ---
Container(
height: 56,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
colors: [primaryColor, primaryColor.withValues(alpha: 0.8)],
),
boxShadow: [
BoxShadow(
color: primaryColor.withValues(alpha: 0.3),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: ElevatedButton.icon(
icon: isSending
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Icon(Icons.send_rounded, size: 20),
label: Text(isSending ? "در حال ارسال..." : "ارسال پیام",
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold)),
onPressed: isSending ? null : _sendMessage,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
),
),
),
],
),
),
],
),
);
}
Widget _buildSmallButton(IconData icon, Color color, VoidCallback onTap) {
return Container(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: IconButton(
icon: Icon(icon, color: color, size: 22),
onPressed: onTap,
splashRadius: 24,
),
);
}
}
// --- Delegate جدید: ورودی List<Contact> ---
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) {
if (contacts.isEmpty) return const Center(child: Text("مخاطبی یافت نشد."));
final filtered = query.isEmpty
? contacts
: contacts.where((c) {
final name = c.displayName.toLowerCase();
final q = query.toLowerCase();
return name.contains(q);
}).toList();
// بررسی اینکه آیا کوئری شبیه شماره تلفن است
final bool isQueryNumeric = query.length >= 3 && RegExp(r'^[0-9+\-*#]+$').hasMatch(query);
if (filtered.isEmpty && !isQueryNumeric)
return const Center(child: Text("نتیجه‌ای یافت نشد."));
return ListView.separated(
itemCount: filtered.length + (isQueryNumeric ? 1 : 0),
separatorBuilder: (ctx, i) => const Divider(height: 1),
itemBuilder: (context, index) {
if (isQueryNumeric && index == 0) {
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.green,
child: Icon(Icons.phone, color: Colors.white),
),
title: Text("افزودن شماره دستی: $query",
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.green)),
subtitle: const Text("استفاده از این شماره"),
onTap: () => close(context, {'name': query, 'phone': query}),
);
}
final contact = filtered[isQueryNumeric ? index - 1 : 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,
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle:
const Text("برای انتخاب کلیک کنید"), // شماره هنوز معلوم نیست
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) {
if (context.mounted)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("این مخاطب شماره تلفن ندارد")));
return;
}
if (fullContact.phones.length == 1) {
close(context, {
'name': fullContact.displayName,
'phone': fullContact.phones.first.number
});
} else {
_showPhoneSelection(context, fullContact);
}
} catch (e) {
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(),
),
);
}
}