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

635 lines
29 KiB
Dart
Raw 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';
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 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(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 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;
},
),
),
],
),
),
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),
],
),
],
),
),
);
},
),
);
}),
)
],
),
);
}
}