marco.pms.mobileapp/lib/view/directory/directory_view.dart

765 lines
39 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import 'package:marco/helpers/widgets/my_confirmation_dialog.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(5),
),
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 dont 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(5),
),
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(5),
),
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: [
// Search + Filter + More menu
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(5)),
),
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)),
),
);
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 Deleted Contacts')),
Switch.adaptive(
value: !controller.isActive.value,
activeColor: Colors.indigo,
onChanged: (val) {
controller.isActive.value = !val;
controller.fetchContacts(active: !val);
Navigator.pop(context);
},
),
],
)),
),
);
return menuItems;
},
),
),
],
),
),
// Contact List
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 isDeleted = !controller
.isActive.value; // mark deleted contacts
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 Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
elevation: 3,
shadowColor: Colors.grey.withOpacity(0.3),
color: Colors.white,
child: InkWell(
borderRadius: BorderRadius.circular(5),
onTap: isDeleted
? null
: () => Get.to(() =>
ContactDetailScreen(
contact: contact)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Avatar
Avatar(
firstName: firstName,
lastName: lastName,
size: 40,
backgroundColor: isDeleted
? Colors.grey.shade400
: null,
),
MySpacing.width(12),
// Contact Info
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
MyText.titleSmall(
contact.name,
fontWeight: 600,
overflow:
TextOverflow.ellipsis,
color: isDeleted
? Colors.grey
: Colors.black87,
),
MyText.bodySmall(
contact.organization,
color: isDeleted
? Colors.grey
: Colors.grey[700],
overflow:
TextOverflow.ellipsis,
),
MySpacing.height(6),
if (contact
.contactEmails.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(
bottom: 4),
child: GestureDetector(
onTap: isDeleted
? null
: () => LauncherUtils
.launchEmail(contact
.contactEmails
.first
.emailAddress),
onLongPress: isDeleted
? null
: () => LauncherUtils
.copyToClipboard(
contact
.contactEmails
.first
.emailAddress,
typeLabel:
'Email',
),
child: Row(
children: [
Icon(
Icons
.email_outlined,
size: 16,
color: isDeleted
? Colors.grey
: Colors
.indigo),
MySpacing.width(4),
Expanded(
child: MyText
.labelSmall(
contact
.contactEmails
.first
.emailAddress,
overflow:
TextOverflow
.ellipsis,
color: isDeleted
? Colors.grey
: 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: isDeleted
? null
: () => LauncherUtils
.launchPhone(contact
.contactPhones
.first
.phoneNumber),
onLongPress:
isDeleted
? null
: () =>
LauncherUtils
.copyToClipboard(
contact
.contactPhones
.first
.phoneNumber,
typeLabel:
'Phone',
),
child: Row(
children: [
Icon(
Icons
.phone_outlined,
size: 16,
color: isDeleted
? Colors
.grey
: Colors
.indigo),
MySpacing.width(
4),
Expanded(
child: MyText
.labelSmall(
contact
.contactPhones
.first
.phoneNumber,
overflow:
TextOverflow
.ellipsis,
color: isDeleted
? Colors
.grey
: Colors
.indigo,
decoration:
TextDecoration
.underline,
),
),
],
),
),
),
MySpacing.width(8),
GestureDetector(
onTap: isDeleted
? null
: () => LauncherUtils
.launchWhatsApp(contact
.contactPhones
.first
.phoneNumber),
child: FaIcon(
FontAwesomeIcons
.whatsapp,
color: isDeleted
? Colors.grey
: Colors
.green,
size: 25),
),
],
),
),
if (tags.isNotEmpty)
Padding(
padding:
const EdgeInsets.only(
top: 0),
child: Wrap(
spacing: 6,
runSpacing: 2,
children: tags
.map(
(tag) => Chip(
label: Text(tag),
backgroundColor:
Colors.indigo
.shade50,
labelStyle: TextStyle(
color: isDeleted
? Colors
.grey
: Colors
.indigo,
fontSize: 12),
visualDensity:
VisualDensity
.compact,
shape:
RoundedRectangleBorder(
borderRadius:
BorderRadius
.circular(
5),
),
),
)
.toList(),
),
),
],
),
),
// Actions Column (Arrow + Icons)
Column(
children: [
IconButton(
icon: Icon(
isDeleted
? Icons.restore
: Icons.delete,
color: isDeleted
? Colors.green
: Colors.redAccent,
size: 20,
),
onPressed: () async {
await Get.dialog(
ConfirmDialog(
title: isDeleted
? "Restore Contact"
: "Delete Contact",
message: isDeleted
? "Are you sure you want to restore this contact?"
: "Are you sure you want to delete this contact?",
confirmText: isDeleted
? "Restore"
: "Delete",
confirmColor: isDeleted
? Colors.green
: Colors.redAccent,
icon: isDeleted
? Icons.restore
: Icons
.delete_forever,
onConfirm: () async {
if (isDeleted) {
await controller
.restoreContact(
contact.id);
} else {
await controller
.deleteContact(
contact.id);
}
},
),
barrierDismissible: false,
);
},
),
const SizedBox(height: 4),
Icon(
Icons.arrow_forward_ios,
color: Colors.grey,
size: 20,
)
],
),
],
),
),
),
);
}));
}),
)
],
),
);
}
}