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

772 lines
29 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:another_telephony/telephony.dart';
import '../utils/contact_helper.dart';
import '../utils/database_helper.dart';
import 'chat_screen.dart';
import 'group_chat_screen.dart';
import 'compose_screen.dart';
import 'settings_screen.dart';
import '../utils/secure_messaging_service.dart';
import '../utils/protocol_helper.dart';
import '../utils/notification_helper.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import '../utils/app_theme.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
List<Map<String, dynamic>> conversations = [];
List<Map<String, dynamic>> groups = [];
List<Contact> contacts = [];
final TextEditingController _contactSearchController =
TextEditingController();
bool isLoading = true;
bool isLoadingContacts = false;
final Telephony telephony = Telephony.instance;
Map<String, int> unreadCounts = {};
Map<int, int> groupUnreadCounts = {};
// رنگ‌های تم حرفه‌ای
Color get primaryColor => Theme.of(context).primaryColor;
Color get secondaryColor => Theme.of(context).colorScheme.secondary;
final Color backgroundColor = AppTheme.darkBg;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() {});
if (_tabController.index == 2 && contacts.isEmpty) {
_loadContacts();
}
});
// Delay heavy initialization until transition finishes (800ms)
// This makes the logo transition buttery smooth (60fps)
Future.delayed(const Duration(milliseconds: 900), () {
if (mounted) initApp();
});
_initMessageStreamListener();
}
StreamSubscription? _messageSubscription;
StreamSubscription? _notificationSubscription;
void _initMessageStreamListener() {
_messageSubscription =
SecureMessagingService.instance.messageStream.listen((data) {
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) loadData();
});
});
_notificationSubscription =
NotificationHelper.instance.notificationStream.listen((payload) {
if (!mounted) return;
if (payload.startsWith('group_')) {
final groupId = int.tryParse(payload.replaceFirst('group_', ''));
if (groupId != null) {
// Find group name
String name = "گروه";
for (var g in groups) {
if (g['id'] == groupId) {
name = g['name'];
break;
}
}
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
GroupChatScreen(groupId: groupId, groupName: name),
),
).then((_) => loadData());
}
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(address: payload),
),
).then((_) => loadData());
}
});
}
@override
void dispose() {
_tabController.dispose();
_contactSearchController.dispose();
_messageSubscription?.cancel();
_notificationSubscription?.cancel();
super.dispose();
}
void initApp() {
loadMessages();
// همگام‌سازی نام مخاطبین در پس‌زمینه
Future.delayed(const Duration(seconds: 1), () async {
// ابتدا از کش محلی لود میکنیم برای سرعت
await ContactHelper.loadFromLocalCache();
if (mounted) setState(() {});
// سپس با دستگاه سینک میکنیم برای آپدیت نام‌های جدید
await ContactHelper.syncWithDevice();
if (mounted) setState(() {});
});
}
void loadData() async {
setState(() => isLoading = true);
await loadMessages();
final g = await DatabaseHelper.instance.getGroups();
final counts = <int, int>{};
for (final group in g) {
counts[group['id']] =
await DatabaseHelper.instance.getGroupUnreadCount(group['id']);
}
if (_tabController.index == 2 || contacts.isNotEmpty) {
await _loadContacts();
}
if (mounted) {
setState(() {
groups = g;
groupUnreadCounts = counts;
isLoading = false;
});
}
}
Future<void> _loadContacts() async {
if (isLoadingContacts) return;
setState(() => isLoadingContacts = true);
try {
final c = await ContactHelper.getContactsLight();
if (mounted) {
setState(() {
contacts = c;
isLoadingContacts = false;
});
}
} catch (e) {
if (mounted) setState(() => isLoadingContacts = false);
}
}
Future<void> _startChatFromContact(Contact contact) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
final full = await ContactHelper.getFullContact(contact.id);
if (mounted) Navigator.pop(context);
if (full == null || full.phones.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("این مخاطب شماره تلفن ندارد")),
);
}
return;
}
String phone = full.phones.first.number;
if (full.phones.length > 1) {
if (!mounted) return;
final selected = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text("انتخاب شماره ${full.displayName}"),
children: full.phones
.map((p) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, p.number),
child: Text(p.number),
))
.toList(),
),
);
if (selected == null) return;
phone = selected;
}
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatScreen(address: phone),
),
).then((_) => loadData());
}
} catch (e) {
if (mounted) Navigator.pop(context);
}
}
Future<void> loadMessages() async {
try {
// Fetch from local cache for instant updates
final localRows = await DatabaseHelper.instance.getConversations();
final installDate =
await SecureMessagingService.instance.getInstallDate();
// Filter by install date
List<Map<String, dynamic>> filtered = localRows.where((m) {
if (installDate == null) return true;
final date = m['date'] as int? ?? 0;
return date >= installDate;
}).toList();
// Fetch unread counts
final counts = <String, int>{};
for (final msg in filtered) {
final addr = msg['address'] as String?;
if (addr != null) {
counts[addr] = await DatabaseHelper.instance.getUnreadCount(addr);
}
}
if (mounted) {
setState(() {
conversations = filtered;
unreadCounts = counts;
isLoading = false;
});
}
} catch (e) {
debugPrint("[SABA] Error in local loadMessages: $e");
if (mounted) setState(() => isLoading = false);
}
}
void _deleteGroup(int groupId) {
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.deleteGroup(groupId);
if (!mounted) return;
loadData();
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text("گروه حذف شد")));
},
child: const Text("حذف"),
),
],
),
);
}
void _deleteIndividualChat(String address) {
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("باشه")),
],
),
);
}
Future<void> _addContactToDevice(String phone) async {
if (!await FlutterContacts.requestPermission()) return;
try {
final contact = Contact(phones: [Phone(phone)]);
await FlutterContacts.openExternalInsert(contact);
if (!mounted) return;
loadData(); // همگام‌سازی مجدد بعد از افزودن
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("خطا در افزودن مخاطب: $e")),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: backgroundColor,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(85),
child: AppTheme.glassWrapper(
radius: 0,
sigma: 18,
child: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: Padding(
padding: const EdgeInsets.only(top: 15),
child: Hero(
tag: 'app_logo',
child: GestureDetector(
onTap: () {
if (mounted) setState(() {});
},
child: const Image(
image: AssetImage('صبا بالا.png'),
height: 42,
fit: BoxFit.contain,
),
),
),
),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined,
color: Colors.white, size: 22),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
).then((_) => loadData()),
),
IconButton(
icon: const Icon(Icons.refresh_rounded,
color: Colors.white, size: 22),
onPressed: loadData,
),
const SizedBox(width: 8),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(50),
child: Container(
color: Colors.transparent, // Let glass show through
child: TabBar(
controller: _tabController,
labelColor: primaryColor,
unselectedLabelColor: Colors.grey,
indicatorColor: primaryColor,
indicatorWeight: 3,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 16),
dividerColor: Colors.transparent,
tabs: const [
Tab(text: "گفتگوها"),
Tab(text: "گروه‌ها"),
Tab(text: "مخاطبین"),
],
),
),
),
),
),
),
body: TabBarView(
controller: _tabController,
children: [
// --- لیست گفتگوها ---
isLoading
? Center(child: CircularProgressIndicator(color: primaryColor))
: conversations.isEmpty
? _buildEmptyState("هیچ گفتگویی وجود ندارد")
: ListView.builder(
padding: const EdgeInsets.only(top: 10, bottom: 80),
itemCount: conversations.length,
itemBuilder: (context, index) {
final msg = conversations[index];
final addr = msg['address'] as String? ?? "";
final displayName = ContactHelper.getName(addr);
final body = msg['body'] as String? ?? "";
final parsed = ProtocolHelper.parseMessage(body);
final isSecure = parsed['type'] != 'plain';
final displayText =
isSecure ? "🔒 پیام امن (رمزگذاری شده)" : body;
return _buildChatCard(
index: index,
title: displayName,
subtitle: displayText,
isEncrypted: isSecure,
unreadCount: unreadCounts[addr] ?? 0,
avatarText:
displayName.isNotEmpty ? displayName[0] : "?",
colorSeed: index,
showAddButton: ContactHelper.isRawNumber(displayName),
onAddContact: () => _addContactToDevice(addr),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
ChatScreen(address: addr)))
.then((_) => loadData()),
onLongPress: () => _deleteIndividualChat(addr),
);
},
),
// --- لیست گروه‌ها ---
groups.isEmpty
? _buildEmptyState("گروهی ساخته نشده است")
: ListView.builder(
padding: const EdgeInsets.only(top: 10, bottom: 80),
itemCount: groups.length,
itemBuilder: (context, index) {
final group = groups[index];
return _buildChatCard(
index: index,
title: group['name'],
subtitle: "پیام گروهی",
isEncrypted: false,
isGroup: true,
unreadCount: groupUnreadCounts[group['id']] ?? 0,
avatarText: "#",
colorSeed: index + 5,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => GroupChatScreen(
groupId: group['id'],
groupName: group['name']))),
onLongPress: () => _deleteGroup(group['id']),
);
},
),
// --- لیست مخاطبین ---
Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: AppTheme.glassWrapper(
radius: 12,
sigma: 5,
child: TextField(
controller: _contactSearchController,
onChanged: (val) => setState(() {}),
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: "جستجو در مخاطبین...",
hintStyle: const TextStyle(color: Colors.white60),
prefixIcon:
const Icon(Icons.search, color: Colors.white70),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
),
),
),
),
Expanded(
child: isLoadingContacts && contacts.isEmpty
? Center(
child: CircularProgressIndicator(color: primaryColor))
: contacts.isEmpty
? _buildEmptyState("مخاطبی یافت نشد")
: Builder(builder: (context) {
final query = _contactSearchController.text
.trim()
.toLowerCase();
final filtered = query.isEmpty
? contacts
: contacts.where((c) {
final name = c.displayName.toLowerCase();
return name.contains(query);
}).toList();
if (filtered.isEmpty) {
return _buildEmptyState("نتیجه‌ای یافت نشد");
}
return ListView.builder(
padding: const EdgeInsets.only(bottom: 80),
itemCount: filtered.length,
itemBuilder: (context, index) {
final contact = filtered[index];
final displayName = contact.displayName;
return _buildChatCard(
index: index,
title: displayName,
subtitle: "شروع گفتگوی جدید",
isEncrypted: false,
avatarText: displayName.isNotEmpty
? displayName[0]
: "?",
colorSeed: index,
onTap: () => _startChatFromContact(contact),
onLongPress: () {},
);
},
);
}),
),
],
),
],
),
floatingActionButton: FloatingActionButton.extended(
backgroundColor: primaryColor,
icon: Icon(_tabController.index == 0 ? Icons.edit : Icons.group_add,
color: Colors.white),
label: Text(_tabController.index == 0 ? "پیام جدید" : "گروه جدید",
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold)),
onPressed: () => Navigator.push(context,
MaterialPageRoute(builder: (_) => const ComposeScreen()))
.then((res) {
if (res == true) loadData();
}),
),
);
}
Widget _buildEmptyState(String text) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size: 80, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(text, style: TextStyle(color: Colors.grey[600], fontSize: 18)),
],
),
);
}
Widget _buildChatCard({
required int index,
required String title,
required String subtitle,
required bool isEncrypted,
required String avatarText,
required int colorSeed,
int unreadCount = 0,
bool isGroup = false,
bool showAddButton = false,
VoidCallback? onAddContact,
required VoidCallback onTap,
required VoidCallback onLongPress,
}) {
final List<Color> avatarColors = [
Colors.blueAccent,
Colors.teal,
Colors.deepPurple,
Colors.indigo,
Colors.orangeAccent,
Colors.pinkAccent,
Colors.cyan
];
final avatarBg =
isGroup ? Colors.orange : avatarColors[colorSeed % avatarColors.length];
// Slide and Fade animation for that premium staggered feel
return TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 450),
tween: Tween(begin: 0.0, end: 1.0),
curve: Curves.easeOutQuart,
// Delay based on index for the staggered effect
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(30 * (1 - value), 0),
child: child,
),
);
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: AppTheme.darkCard,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Colors.white.withValues(alpha: 0.05), width: 0.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
spreadRadius: 1,
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(18),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.all(14.0),
child: Row(
children: [
Hero(
tag: 'avatar_${title}_${isGroup ? 'group' : 'ind'}',
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
avatarBg,
avatarBg.withValues(alpha: 0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: avatarBg.withValues(alpha: 0.3),
blurRadius: 8,
spreadRadius: 1,
),
],
),
child: Center(
child: isGroup
? const Icon(Icons.groups,
color: Colors.white, size: 28)
: Text(
avatarText,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold),
),
),
),
if (unreadCount > 0)
Positioned(
top: -4,
left: -4,
child: Container(
constraints: const BoxConstraints(
minWidth: 22,
minHeight: 22,
),
padding:
const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: const Color(0xFFFF5A5F),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppTheme.darkCard,
width: 2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFFFF5A5F)
.withValues(alpha: 0.35),
blurRadius: 10,
spreadRadius: 1,
),
],
),
child: Center(
child: Text(
unreadCount > 99
? '99+'
: unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w800,
),
textAlign: TextAlign.center,
),
),
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: unreadCount > 0
? FontWeight.w900
: FontWeight.bold,
fontSize: 17,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
textAlign: TextAlign.right,
),
const SizedBox(height: 4),
Row(
children: [
if (isEncrypted)
Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.lock,
size: 14, color: primaryColor),
),
Expanded(
child: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: unreadCount > 0
? Colors.white.withValues(alpha: 0.9)
: Colors.white.withValues(alpha: 0.6),
fontSize: 13,
fontWeight: unreadCount > 0
? FontWeight.w600
: FontWeight.w400,
),
),
),
],
),
],
),
),
if (showAddButton)
IconButton(
icon:
Icon(Icons.person_add_outlined, color: primaryColor),
onPressed: onAddContact,
tooltip: "افزودن به مخاطبین",
),
Icon(Icons.chevron_right, color: Colors.grey[300]),
],
),
),
),
),
),
);
}
}