Vaibhav_Feature-#768 #59

Closed
vaibhav.surve wants to merge 74 commits from Vaibhav_Feature-#768 into Feature_Expense
6 changed files with 330 additions and 193 deletions
Showing only changes of commit 3195fdd4a0 - Show all commits

View File

@ -3,6 +3,7 @@ import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/model/employee_model.dart';
import 'package:flutter/material.dart';
class ExpenseDetailController extends GetxController {
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
@ -10,9 +11,11 @@ class ExpenseDetailController extends GetxController {
final RxString errorMessage = ''.obs;
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
late String _expenseId;
bool _isInitialized = false;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
/// Call this once from the screen (NOT inside build) to initialize
void init(String expenseId) {
@ -93,6 +96,23 @@ class ExpenseDetailController extends GetxController {
return [];
}
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
);
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
/// Fetch all employees
Future<void> fetchAllEmployees() async {
final response = await _apiCallWrapper(
@ -151,13 +171,13 @@ class ExpenseDetailController extends GetxController {
() => ApiService.updateExpenseStatusApi(
expenseId: _expenseId,
statusId: statusId,
comment: comment,
comment: comment,
),
"update expense status",
);
if (success == true) {
await fetchExpenseDetails();
await fetchExpenseDetails();
return true;
} else {
errorMessage.value = "Failed to update expense status.";

View File

@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
/// Returns a formatted color for the expense status.
Color getExpenseStatusColor(String? status, {String? colorCode}) {
if (colorCode != null && colorCode.isNotEmpty) {
try {
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
} catch (_) {}
}
switch (status) {
case 'Approval Pending':
return Colors.orange;
case 'Process Pending':
return Colors.blue;
case 'Rejected':
return Colors.red;
case 'Paid':
return Colors.green;
default:
return Colors.black;
}
}
/// Formats amount to currency string.
String formatExpenseAmount(double amount) {
return NumberFormat.currency(
locale: 'en_IN', symbol: '', decimalDigits: 2)
.format(amount);
}
/// Label/Value block as reusable widget.
Widget labelValueBlock(String label, String value) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(label, fontWeight: 600),
MySpacing.height(4),
MyText.bodySmall(value,
fontWeight: 500, softWrap: true, maxLines: null),
],
);
/// Skeleton loader for lists.
Widget buildLoadingSkeleton() => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 5,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.only(bottom: 16),
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300], borderRadius: BorderRadius.circular(10)),
),
);
/// Expandable description widget.
class ExpandableDescription extends StatefulWidget {
final String description;
const ExpandableDescription({Key? key, required this.description})
: super(key: key);
@override
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
}
class _ExpandableDescriptionState extends State<ExpandableDescription> {
bool isExpanded = false;
@override
Widget build(BuildContext context) {
final isLong = widget.description.length > 100;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
widget.description,
maxLines: isExpanded ? null : 2,
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
fontWeight: 500,
),
if (isLong || !isExpanded)
InkWell(
onTap: () => setState(() => isExpanded = !isExpanded),
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: MyText.labelSmall(
isExpanded ? 'Show less' : 'Show more',
fontWeight: 600,
color: Colors.blue,
),
),
),
],
);
}
}

View File

@ -48,7 +48,13 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
backgroundColor: Colors.transparent,
builder: (_) => EmployeeSelectorBottomSheet(),
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedPaidBy.value = emp,
),
);
// Optional cleanup

View File

@ -1,14 +1,25 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/model/employee_model.dart';
class EmployeeSelectorBottomSheet extends StatelessWidget {
final AddExpenseController controller = Get.find<AddExpenseController>();
class ReusableEmployeeSelectorBottomSheet extends StatelessWidget {
final TextEditingController searchController;
final RxList<EmployeeModel> searchResults;
final RxBool isSearching;
final void Function(String) onSearch;
final void Function(EmployeeModel) onSelect;
EmployeeSelectorBottomSheet({super.key});
const ReusableEmployeeSelectorBottomSheet({
super.key,
required this.searchController,
required this.searchResults,
required this.isSearching,
required this.onSearch,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
@ -16,13 +27,13 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
title: "Search Employee",
onCancel: () => Get.back(),
onSubmit: () {},
showButtons: false,
showButtons: false,
child: Obx(() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller.employeeSearchController,
controller: searchController,
decoration: InputDecoration(
hintText: "Search by name, email...",
prefixIcon: const Icon(Icons.search),
@ -32,24 +43,24 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
onChanged: (value) => controller.searchEmployees(value),
onChanged: onSearch,
),
MySpacing.height(12),
SizedBox(
height: 400, // Adjust this if needed
child: controller.isSearchingEmployees.value
height: 400,
child: isSearching.value
? const Center(child: CircularProgressIndicator())
: controller.employeeSearchResults.isEmpty
? Center(
: searchResults.isEmpty
? Center(
child: MyText.bodyMedium(
"No employees found.",
fontWeight: 500,
),
)
: ListView.builder(
itemCount: controller.employeeSearchResults.length,
itemCount: searchResults.length,
itemBuilder: (_, index) {
final emp = controller.employeeSearchResults[index];
final emp = searchResults[index];
final fullName =
'${emp.firstName} ${emp.lastName}'.trim();
return ListTile(
@ -58,7 +69,7 @@ class EmployeeSelectorBottomSheet extends StatelessWidget {
fontWeight: 600,
),
onTap: () {
controller.selectedPaidBy.value = emp;
onSelect(emp);
Get.back();
},
);

View File

@ -8,7 +8,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
class ReimbursementBottomSheet extends StatefulWidget {
final String expenseId;
@ -50,39 +50,26 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
super.dispose();
}
void _showEmployeeList() {
showModalBottomSheet(
void _showEmployeeList() async {
await showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) {
return SizedBox(
height: 300,
child: Obx(() {
final employees = controller.allEmployees;
if (employees.isEmpty) {
return const Center(child: Text("No employees found"));
}
return ListView.builder(
itemCount: employees.length,
itemBuilder: (_, index) {
final emp = employees[index];
final fullName = '${emp.firstName} ${emp.lastName}'.trim();
return ListTile(
title: Text(fullName.isNotEmpty ? fullName : "Unnamed"),
onTap: () {
controller.selectedReimbursedBy.value = emp;
Navigator.pop(context);
},
);
},
);
}),
);
},
backgroundColor: Colors.transparent,
builder: (_) => ReusableEmployeeSelectorBottomSheet(
searchController: controller.employeeSearchController,
searchResults: controller.employeeSearchResults,
isSearching: controller.isSearchingEmployees,
onSearch: controller.searchEmployees,
onSelect: (emp) => controller.selectedReimbursedBy.value = emp,
),
);
// Optional cleanup
controller.employeeSearchController.clear();
controller.employeeSearchResults.clear();
}
InputDecoration _inputDecoration(String hint) {
@ -200,16 +187,25 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
MySpacing.height(8),
GestureDetector(
onTap: _showEmployeeList,
child: AbsorbPointer(
child: TextField(
controller: TextEditingController(
text: controller.selectedReimbursedBy.value == null
? ""
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
),
decoration: _inputDecoration("Select Employee").copyWith(
suffixIcon: const Icon(Icons.expand_more),
),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.selectedReimbursedBy.value == null
? "Select Paid By"
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
style: const TextStyle(fontSize: 14),
),
const Icon(Icons.arrow_drop_down, size: 22),
],
),
),
),

View File

@ -1,67 +1,97 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:marco/model/expense/add_expense_bottom_sheet.dart';
import 'package:marco/controller/expense/expense_detail_controller.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/controller/permission_controller.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:marco/model/expense/reimbursement_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/add_expense_bottom_sheet.dart';
import 'package:marco/model/expense/comment_bottom_sheet.dart';
import 'package:marco/model/expense/expense_detail_model.dart';
import 'package:marco/model/expense/reimbursement_bottom_sheet.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:url_launcher/url_launcher.dart';
class ExpenseDetailScreen extends StatelessWidget {
import 'package:marco/helpers/widgets/expense_detail_helpers.dart';
import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/model/employee_info.dart';
class ExpenseDetailScreen extends StatefulWidget {
final String expenseId;
const ExpenseDetailScreen({super.key, required this.expenseId});
static Color getStatusColor(String? status, {String? colorCode}) {
if (colorCode != null && colorCode.isNotEmpty) {
try {
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
} catch (_) {}
}
switch (status) {
case 'Approval Pending':
return Colors.orange;
case 'Process Pending':
return Colors.blue;
case 'Rejected':
return Colors.red;
case 'Paid':
return Colors.green;
default:
return Colors.black;
}
@override
State<ExpenseDetailScreen> createState() => _ExpenseDetailScreenState();
}
class _ExpenseDetailScreenState extends State<ExpenseDetailScreen> {
final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>();
final permissionController = Get.find<PermissionController>();
EmployeeInfo? employeeInfo;
final RxBool canSubmit = false.obs;
bool _checkedPermission = false;
@override
void initState() {
super.initState();
controller.init(widget.expenseId);
_loadEmployeeInfo();
}
void _loadEmployeeInfo() async {
final info = await LocalStorage.getEmployeeInfo();
employeeInfo = info;
}
void _checkPermissionToSubmit(ExpenseDetailModel expense) {
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id;
final nextStatusIds = expense.nextStatus.map((e) => e.id).toList();
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
logSafe(
'🐛 Checking submit permission:\n'
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
'🐛 - Expense created by ID: ${expense.createdBy.id}\n'
'🐛 - Next Status IDs: $nextStatusIds\n'
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
'🐛 - Final Permission Result: $result',
level: LogLevel.debug,
);
canSubmit.value = result;
}
@override
Widget build(BuildContext context) {
final controller = Get.put(ExpenseDetailController());
final projectController = Get.find<ProjectController>();
controller.init(expenseId);
final permissionController = Get.find<PermissionController>();
return Scaffold(
backgroundColor: const Color(0xFFF7F7F7),
appBar: _AppBar(projectController: projectController),
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value) return _buildLoadingSkeleton();
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
final statusColor = getStatusColor(expense.status.name,
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor(expense.status.name,
colorCode: expense.status.color);
final formattedAmount = _formatAmount(expense.amount);
final formattedAmount = formatExpenseAmount(expense.amount);
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
8, 8, 8, 30 + MediaQuery.of(context).padding.bottom),
@ -87,9 +117,10 @@ class ExpenseDetailScreen extends StatelessWidget {
_InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2),
_InvoiceTotals(
expense: expense,
formattedAmount: formattedAmount,
statusColor: statusColor),
expense: expense,
formattedAmount: formattedAmount,
statusColor: statusColor,
),
],
),
),
@ -100,18 +131,21 @@ class ExpenseDetailScreen extends StatelessWidget {
}),
),
floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (expense == null) return const SizedBox.shrink();
if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display."));
}
// Allowed status Ids
const allowedStatusIds = [
"d1ee5eec-24b6-4364-8673-a8f859c60729",
"965eda62-7907-4963-b4a1-657fb0b2724b",
"297e0d8f-f668-41b5-bfea-e03b354251c8"
];
if (!_checkedPermission) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
}
// Show edit button only if status id is in allowedStatusIds
if (!allowedStatusIds.contains(expense.status.id)) {
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
return const SizedBox.shrink();
}
@ -130,10 +164,8 @@ class ExpenseDetailScreen extends StatelessWidget {
'expensesTypeId': expense.expensesType.id,
'paymentModeId': expense.paymentMode.id,
'paidById': expense.paidBy.id,
// ==== Add these lines below ====
'paidByFirstName': expense.paidBy.firstName,
'paidByLastName': expense.paidBy.lastName,
// =================================
'attachments': expense.documents
.map((doc) => {
'url': doc.preSignedUrl,
@ -151,20 +183,17 @@ class ExpenseDetailScreen extends StatelessWidget {
addCtrl.populateFieldsForEdit(editData);
await showAddExpenseBottomSheet(isEdit: true);
// Refresh expense details after editing
await controller.fetchExpenseDetails();
},
backgroundColor: Colors.red,
tooltip: 'Edit Expense',
child: Icon(Icons.edit),
child: const Icon(Icons.edit),
);
}),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null || expense.nextStatus.isEmpty) {
return const SizedBox();
}
if (expense == null) return const SizedBox();
return SafeArea(
child: Container(
decoration: const BoxDecoration(
@ -176,12 +205,37 @@ class ExpenseDetailScreen extends StatelessWidget {
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: expense.nextStatus
.where((next) => permissionController.hasAnyPermission(
controller.parsePermissionIds(next.permissionIds)))
.map((next) =>
_statusButton(context, controller, expense, next))
.toList(),
children: expense.nextStatus.where((next) {
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final rawPermissions = next.permissionIds;
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser =
employeeInfo?.id == expense.createdBy.id;
logSafe(
'🔐 Permission Logic:\n'
'🔸 Status: ${next.name}\n'
'🔸 Status ID: ${next.id}\n'
'🔸 Parsed Permissions: $parsedPermissions\n'
'🔸 Is Submit: $isSubmitStatus\n'
'🔸 Created By Current User: $isCreatedByCurrentUser',
level: LogLevel.debug,
);
if (isSubmitStatus) {
// Submit can be done ONLY by the creator
return isCreatedByCurrentUser;
}
// All other statuses - check permission normally
return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) {
return _statusButton(context, controller, expense, next);
}).toList(),
),
),
);
@ -197,6 +251,7 @@ class ExpenseDetailScreen extends StatelessWidget {
buttonColor = Color(int.parse(next.color.replaceFirst('#', '0xff')));
} catch (_) {}
}
return ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 40),
@ -205,7 +260,6 @@ class ExpenseDetailScreen extends StatelessWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
onPressed: () async {
// For brevity, couldn't refactor the logic since it's business-specific.
const reimbursementId = 'f18c5cfd-7815-4341-8da2-2c2d65778e27';
if (expense.status.id == reimbursementId) {
showModalBottomSheet(
@ -277,25 +331,6 @@ class ExpenseDetailScreen extends StatelessWidget {
),
);
}
static String _formatAmount(double amount) {
return NumberFormat.currency(
locale: 'en_IN', symbol: '', decimalDigits: 2)
.format(amount);
}
Widget _buildLoadingSkeleton() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 5,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.only(bottom: 16),
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300], borderRadius: BorderRadius.circular(10)),
),
);
}
}
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
@ -356,8 +391,6 @@ class _AppBar extends StatelessWidget implements PreferredSizeWidget {
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
// -------- Invoice Sub-Components, unchanged except formatting/const ----------------
class _InvoiceHeader extends StatelessWidget {
final ExpenseDetailModel expense;
const _InvoiceHeader({required this.expense});
@ -366,7 +399,7 @@ class _InvoiceHeader extends StatelessWidget {
final dateString = DateTimeUtils.convertUtcToLocal(
expense.transactionDate.toString(),
format: 'dd-MM-yyyy');
final statusColor = ExpenseDetailScreen.getStatusColor(expense.status.name,
final statusColor = getExpenseStatusColor(expense.status.name,
colorCode: expense.status.color);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -407,28 +440,18 @@ class _InvoiceParties extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_labelValueBlock('Project', expense.project.name),
labelValueBlock('Project', expense.project.name),
MySpacing.height(16),
_labelValueBlock('Paid By:',
labelValueBlock('Paid By:',
'${expense.paidBy.firstName} ${expense.paidBy.lastName}'),
MySpacing.height(16),
_labelValueBlock('Supplier', expense.supplerName),
labelValueBlock('Supplier', expense.supplerName),
MySpacing.height(16),
_labelValueBlock('Created By:',
labelValueBlock('Created By:',
'${expense.createdBy.firstName} ${expense.createdBy.lastName}'),
],
);
}
Widget _labelValueBlock(String label, String value) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(label, fontWeight: 600),
MySpacing.height(4),
MyText.bodySmall(value,
fontWeight: 500, softWrap: true, maxLines: null),
],
);
}
class _InvoiceDetailsTable extends StatelessWidget {
@ -556,6 +579,29 @@ class _InvoiceDocuments extends StatelessWidget {
}
}
class ExpensePermissionHelper {
static bool canEditExpense(
EmployeeInfo? employee, ExpenseDetailModel expense) {
return employee?.id == expense.createdBy.id &&
_isInAllowedEditStatus(expense.status.id);
}
static bool canSubmitExpense(
EmployeeInfo? employee, ExpenseDetailModel expense) {
return employee?.id == expense.createdBy.id &&
expense.nextStatus.isNotEmpty;
}
static bool _isInAllowedEditStatus(String statusId) {
const editableStatusIds = [
"d1ee5eec-24b6-4364-8673-a8f859c60729",
"965eda62-7907-4963-b4a1-657fb0b2724b",
"297e0d8f-f668-41b5-bfea-e03b354251c8"
];
return editableStatusIds.contains(statusId);
}
}
class _InvoiceTotals extends StatelessWidget {
final ExpenseDetailModel expense;
final String formattedAmount;
@ -576,41 +622,3 @@ class _InvoiceTotals extends StatelessWidget {
);
}
}
class ExpandableDescription extends StatefulWidget {
final String description;
const ExpandableDescription({super.key, required this.description});
@override
State<ExpandableDescription> createState() => _ExpandableDescriptionState();
}
class _ExpandableDescriptionState extends State<ExpandableDescription> {
bool isExpanded = false;
@override
Widget build(BuildContext context) {
final isLong = widget.description.length > 100;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall(
widget.description,
maxLines: isExpanded ? null : 2,
overflow: isExpanded ? TextOverflow.visible : TextOverflow.ellipsis,
fontWeight: 500,
),
if (isLong || !isExpanded)
InkWell(
onTap: () => setState(() => isExpanded = !isExpanded),
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: MyText.labelSmall(
isExpanded ? 'Show less' : 'Show more',
fontWeight: 600,
color: Colors.blue,
),
),
),
],
);
}
}