635 lines
29 KiB
Dart
635 lines
29 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
||
|
||
import 'package:marco/controller/directory/directory_controller.dart';
|
||
import 'package:marco/controller/directory/create_bucket_controller.dart';
|
||
import 'package:marco/helpers/utils/launcher_utils.dart';
|
||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||
import 'package:marco/helpers/widgets/my_text.dart';
|
||
import 'package:marco/helpers/widgets/avatar.dart';
|
||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||
import 'package:marco/model/directory/add_contact_bottom_sheet.dart';
|
||
import 'package:marco/model/directory/directory_filter_bottom_sheet.dart';
|
||
import 'package:marco/model/directory/create_bucket_bottom_sheet.dart';
|
||
import 'package:marco/view/directory/contact_detail_screen.dart';
|
||
import 'package:marco/view/directory/manage_bucket_screen.dart';
|
||
import 'package:marco/controller/permission_controller.dart';
|
||
import 'package:marco/helpers/utils/permission_constants.dart';
|
||
|
||
class DirectoryView extends StatefulWidget {
|
||
@override
|
||
State<DirectoryView> createState() => _DirectoryViewState();
|
||
}
|
||
|
||
class _DirectoryViewState extends State<DirectoryView> {
|
||
final DirectoryController controller = Get.find();
|
||
final TextEditingController searchController = TextEditingController();
|
||
final PermissionController permissionController =
|
||
Get.put(PermissionController());
|
||
|
||
Future<void> _refreshDirectory() async {
|
||
try {
|
||
await controller.fetchContacts();
|
||
} catch (e, stackTrace) {
|
||
debugPrint('Error refreshing directory data: ${e.toString()}');
|
||
debugPrintStack(stackTrace: stackTrace);
|
||
}
|
||
}
|
||
|
||
void _handleCreateContact() async {
|
||
await controller.fetchBuckets();
|
||
|
||
if (controller.contactBuckets.isEmpty) {
|
||
final shouldCreate = await showModalBottomSheet<bool>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => _buildEmptyBucketPrompt(),
|
||
);
|
||
if (shouldCreate != true) return;
|
||
|
||
final created = await showModalBottomSheet<bool>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => const CreateBucketBottomSheet(),
|
||
);
|
||
if (created == true) {
|
||
await controller.fetchBuckets();
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
|
||
Get.delete<BucketController>();
|
||
|
||
final result = await Get.bottomSheet(
|
||
AddContactBottomSheet(),
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
);
|
||
|
||
if (result == true) {
|
||
controller.fetchContacts();
|
||
}
|
||
}
|
||
|
||
void _handleManageBuckets() async {
|
||
await controller.fetchBuckets();
|
||
Get.to(
|
||
() => ManageBucketsScreen(permissionController: permissionController));
|
||
}
|
||
|
||
Widget _buildEmptyBucketPrompt() {
|
||
return SafeArea(
|
||
child: Container(
|
||
margin: const EdgeInsets.all(16),
|
||
padding: const EdgeInsets.all(20),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.info_outline, size: 48, color: Colors.indigo),
|
||
MySpacing.height(12),
|
||
MyText.titleMedium("No Buckets Assigned",
|
||
fontWeight: 700, textAlign: TextAlign.center),
|
||
MySpacing.height(8),
|
||
MyText.bodyMedium(
|
||
"You don’t have any buckets assigned. Please create a bucket before adding a contact.",
|
||
textAlign: TextAlign.center,
|
||
color: Colors.grey[700],
|
||
),
|
||
MySpacing.height(20),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: ElevatedButton(
|
||
onPressed: () => Navigator.pop(context, false),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.grey[300],
|
||
foregroundColor: Colors.black,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
),
|
||
child: const Text("Cancel"),
|
||
),
|
||
),
|
||
MySpacing.width(12),
|
||
Expanded(
|
||
child: ElevatedButton(
|
||
onPressed: () => Navigator.pop(context, true),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: Colors.indigo,
|
||
foregroundColor: Colors.white,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
),
|
||
child: const Text("Create Bucket"),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildEmptyState() {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.perm_contact_cal, size: 60, color: Colors.grey),
|
||
MySpacing.height(18),
|
||
MyText.titleMedium(
|
||
'No matching contacts found.',
|
||
fontWeight: 600,
|
||
color: Colors.grey,
|
||
),
|
||
MySpacing.height(10),
|
||
MyText.bodySmall(
|
||
'Try adjusting your filters or refresh to reload.',
|
||
color: Colors.grey,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: Colors.grey[100],
|
||
floatingActionButton: FloatingActionButton.extended(
|
||
heroTag: 'createContact',
|
||
backgroundColor: Colors.red,
|
||
onPressed: _handleCreateContact,
|
||
icon: const Icon(Icons.person_add_alt_1, color: Colors.white),
|
||
label: const Text("Add Contact", style: TextStyle(color: Colors.white)),
|
||
),
|
||
body: Column(
|
||
children: [
|
||
Padding(
|
||
padding: MySpacing.xy(8, 8),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: SizedBox(
|
||
height: 35,
|
||
child: TextField(
|
||
controller: searchController,
|
||
onChanged: (value) {
|
||
controller.searchQuery.value = value;
|
||
controller.applyFilters();
|
||
},
|
||
decoration: InputDecoration(
|
||
contentPadding:
|
||
const EdgeInsets.symmetric(horizontal: 12),
|
||
prefixIcon: const Icon(Icons.search,
|
||
size: 20, color: Colors.grey),
|
||
suffixIcon: ValueListenableBuilder<TextEditingValue>(
|
||
valueListenable: searchController,
|
||
builder: (context, value, _) {
|
||
if (value.text.isEmpty) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return IconButton(
|
||
icon: const Icon(Icons.clear,
|
||
size: 20, color: Colors.grey),
|
||
onPressed: () {
|
||
searchController.clear();
|
||
controller.searchQuery.value = '';
|
||
controller.applyFilters();
|
||
},
|
||
);
|
||
},
|
||
),
|
||
hintText: 'Search contacts...',
|
||
filled: true,
|
||
fillColor: Colors.white,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(5),
|
||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(5),
|
||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
MySpacing.width(8),
|
||
Obx(() {
|
||
final isFilterActive = controller.hasActiveFilters();
|
||
return Stack(
|
||
children: [
|
||
Container(
|
||
height: 35,
|
||
width: 35,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border.all(color: Colors.grey.shade300),
|
||
borderRadius: BorderRadius.circular(5),
|
||
),
|
||
child: IconButton(
|
||
icon: Icon(Icons.tune,
|
||
size: 20,
|
||
color: isFilterActive
|
||
? Colors.indigo
|
||
: Colors.black87),
|
||
onPressed: () {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(
|
||
top: Radius.circular(20)),
|
||
),
|
||
builder: (_) =>
|
||
const DirectoryFilterBottomSheet(),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
if (isFilterActive)
|
||
Positioned(
|
||
top: 6,
|
||
right: 6,
|
||
child: Container(
|
||
height: 8,
|
||
width: 8,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.red,
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}),
|
||
MySpacing.width(10),
|
||
Container(
|
||
height: 35,
|
||
width: 35,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border.all(color: Colors.grey.shade300),
|
||
borderRadius: BorderRadius.circular(5),
|
||
),
|
||
child: PopupMenuButton<int>(
|
||
padding: EdgeInsets.zero,
|
||
icon: const Icon(Icons.more_vert,
|
||
size: 20, color: Colors.black87),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(5),
|
||
),
|
||
itemBuilder: (context) {
|
||
List<PopupMenuEntry<int>> menuItems = [];
|
||
|
||
// Section: Actions
|
||
menuItems.add(
|
||
const PopupMenuItem<int>(
|
||
enabled: false,
|
||
height: 30,
|
||
child: Text(
|
||
"Actions",
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.grey,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
// ✅ Conditionally show Create Bucket option
|
||
if (permissionController
|
||
.hasPermission(Permissions.directoryAdmin) ||
|
||
permissionController
|
||
.hasPermission(Permissions.directoryManager) ||
|
||
permissionController
|
||
.hasPermission(Permissions.directoryUser)) {
|
||
menuItems.add(
|
||
PopupMenuItem<int>(
|
||
value: 2,
|
||
child: Row(
|
||
children: const [
|
||
Icon(Icons.add_box_outlined,
|
||
size: 20, color: Colors.black87),
|
||
SizedBox(width: 10),
|
||
Expanded(child: Text("Create Bucket")),
|
||
Icon(Icons.chevron_right,
|
||
size: 20, color: Colors.red),
|
||
],
|
||
),
|
||
onTap: () {
|
||
Future.delayed(Duration.zero, () async {
|
||
final created =
|
||
await showModalBottomSheet<bool>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) =>
|
||
const CreateBucketBottomSheet(),
|
||
);
|
||
if (created == true) {
|
||
await controller.fetchBuckets();
|
||
}
|
||
});
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
// Manage Buckets option
|
||
menuItems.add(
|
||
PopupMenuItem<int>(
|
||
value: 1,
|
||
child: Row(
|
||
children: const [
|
||
Icon(Icons.label_outline,
|
||
size: 20, color: Colors.black87),
|
||
SizedBox(width: 10),
|
||
Expanded(child: Text("Manage Buckets")),
|
||
Icon(Icons.chevron_right,
|
||
size: 20, color: Colors.red),
|
||
],
|
||
),
|
||
onTap: () {
|
||
Future.delayed(Duration.zero, () {
|
||
_handleManageBuckets();
|
||
});
|
||
},
|
||
),
|
||
);
|
||
|
||
// Section: Preferences
|
||
menuItems.add(
|
||
const PopupMenuItem<int>(
|
||
enabled: false,
|
||
height: 30,
|
||
child: Text(
|
||
"Preferences",
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.grey,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
// Show Inactive toggle
|
||
menuItems.add(
|
||
PopupMenuItem<int>(
|
||
value: 0,
|
||
enabled: false,
|
||
child: Obx(() => Row(
|
||
children: [
|
||
const Icon(Icons.visibility_off_outlined,
|
||
size: 20, color: Colors.black87),
|
||
const SizedBox(width: 10),
|
||
const Expanded(child: Text('Show Inactive')),
|
||
Switch.adaptive(
|
||
value: !controller.isActive.value,
|
||
activeColor: Colors.indigo,
|
||
onChanged: (val) {
|
||
controller.isActive.value = !val;
|
||
controller.fetchContacts(active: !val);
|
||
Navigator.pop(context);
|
||
},
|
||
),
|
||
],
|
||
)),
|
||
),
|
||
);
|
||
|
||
return menuItems;
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Expanded(
|
||
child: Obx(() {
|
||
return MyRefreshIndicator(
|
||
onRefresh: _refreshDirectory,
|
||
backgroundColor: Colors.indigo,
|
||
color: Colors.white,
|
||
child: controller.isLoading.value
|
||
? ListView.separated(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
itemCount: 10,
|
||
separatorBuilder: (_, __) => MySpacing.height(12),
|
||
itemBuilder: (_, __) =>
|
||
SkeletonLoaders.contactSkeletonCard(),
|
||
)
|
||
: controller.filteredContacts.isEmpty
|
||
? _buildEmptyState()
|
||
: ListView.separated(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
padding: MySpacing.only(
|
||
left: 8, right: 8, top: 4, bottom: 80),
|
||
itemCount: controller.filteredContacts.length,
|
||
separatorBuilder: (_, __) => MySpacing.height(12),
|
||
itemBuilder: (_, index) {
|
||
final contact =
|
||
controller.filteredContacts[index];
|
||
final nameParts = contact.name.trim().split(" ");
|
||
final firstName = nameParts.first;
|
||
final lastName =
|
||
nameParts.length > 1 ? nameParts.last : "";
|
||
final tags =
|
||
contact.tags.map((tag) => tag.name).toList();
|
||
|
||
return InkWell(
|
||
onTap: () {
|
||
Get.to(() =>
|
||
ContactDetailScreen(contact: contact));
|
||
},
|
||
child: Padding(
|
||
padding:
|
||
const EdgeInsets.fromLTRB(12, 10, 12, 0),
|
||
child: Row(
|
||
crossAxisAlignment:
|
||
CrossAxisAlignment.start,
|
||
children: [
|
||
Avatar(
|
||
firstName: firstName,
|
||
lastName: lastName,
|
||
size: 35),
|
||
MySpacing.width(12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment:
|
||
CrossAxisAlignment.start,
|
||
children: [
|
||
MyText.titleSmall(contact.name,
|
||
fontWeight: 600,
|
||
overflow:
|
||
TextOverflow.ellipsis),
|
||
MyText.bodySmall(
|
||
contact.organization,
|
||
color: Colors.grey[700],
|
||
overflow:
|
||
TextOverflow.ellipsis),
|
||
MySpacing.height(8),
|
||
if (contact
|
||
.contactEmails.isNotEmpty)
|
||
GestureDetector(
|
||
onTap: () =>
|
||
LauncherUtils.launchEmail(
|
||
contact
|
||
.contactEmails
|
||
.first
|
||
.emailAddress),
|
||
onLongPress: () => LauncherUtils
|
||
.copyToClipboard(
|
||
contact.contactEmails.first
|
||
.emailAddress,
|
||
typeLabel: 'Email',
|
||
),
|
||
child: Padding(
|
||
padding:
|
||
const EdgeInsets.only(
|
||
bottom: 4),
|
||
child: Row(
|
||
children: [
|
||
const Icon(
|
||
Icons.email_outlined,
|
||
size: 16,
|
||
color: Colors.indigo),
|
||
MySpacing.width(4),
|
||
Expanded(
|
||
child:
|
||
MyText.labelSmall(
|
||
contact
|
||
.contactEmails
|
||
.first
|
||
.emailAddress,
|
||
overflow: TextOverflow
|
||
.ellipsis,
|
||
color: Colors.indigo,
|
||
decoration:
|
||
TextDecoration
|
||
.underline,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (contact
|
||
.contactPhones.isNotEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.only(
|
||
bottom: 8, top: 4),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: GestureDetector(
|
||
onTap: () => LauncherUtils
|
||
.launchPhone(contact
|
||
.contactPhones
|
||
.first
|
||
.phoneNumber),
|
||
onLongPress: () =>
|
||
LauncherUtils
|
||
.copyToClipboard(
|
||
contact
|
||
.contactPhones
|
||
.first
|
||
.phoneNumber,
|
||
typeLabel: 'Phone',
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const Icon(
|
||
Icons
|
||
.phone_outlined,
|
||
size: 16,
|
||
color: Colors
|
||
.indigo),
|
||
MySpacing.width(4),
|
||
Expanded(
|
||
child: MyText
|
||
.labelSmall(
|
||
contact
|
||
.contactPhones
|
||
.first
|
||
.phoneNumber,
|
||
overflow:
|
||
TextOverflow
|
||
.ellipsis,
|
||
color: Colors
|
||
.indigo,
|
||
decoration:
|
||
TextDecoration
|
||
.underline,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
MySpacing.width(8),
|
||
GestureDetector(
|
||
onTap: () => LauncherUtils
|
||
.launchWhatsApp(
|
||
contact
|
||
.contactPhones
|
||
.first
|
||
.phoneNumber),
|
||
child: const FaIcon(
|
||
FontAwesomeIcons
|
||
.whatsapp,
|
||
color: Colors.green,
|
||
size: 25,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (tags.isNotEmpty) ...[
|
||
MySpacing.height(2),
|
||
MyText.labelSmall(tags.join(', '),
|
||
color: Colors.grey[500],
|
||
maxLines: 1,
|
||
overflow:
|
||
TextOverflow.ellipsis),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
Column(
|
||
children: [
|
||
const Icon(Icons.arrow_forward_ios,
|
||
color: Colors.grey, size: 16),
|
||
MySpacing.height(8),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}),
|
||
)
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|