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

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