// expense_form_widgets.dart import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/controller/expense/add_expense_controller.dart'; /// 🔹 Common Colors & Styles final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]); final _tileDecoration = BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey.shade300), ); /// ========================== /// Section Title /// ========================== class SectionTitle extends StatelessWidget { final IconData icon; final String title; final bool requiredField; const SectionTitle({ required this.icon, required this.title, this.requiredField = false, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { final color = Colors.grey[700]; return Row( children: [ Icon(icon, color: color, size: 18), const SizedBox(width: 8), RichText( text: TextSpan( style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: Colors.black87, ), children: [ TextSpan(text: title), if (requiredField) const TextSpan( text: ' *', style: TextStyle(color: Colors.red), ), ], ), ), ], ); } } /// ========================== /// Custom Text Field /// ========================== class CustomTextField extends StatelessWidget { final TextEditingController controller; final String hint; final int maxLines; final TextInputType keyboardType; final String? Function(String?)? validator; const CustomTextField({ required this.controller, required this.hint, this.maxLines = 1, this.keyboardType = TextInputType.text, this.validator, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return TextFormField( controller: controller, maxLines: maxLines, keyboardType: keyboardType, validator: validator, decoration: InputDecoration( hintText: hint, hintStyle: _hintStyle, filled: true, fillColor: Colors.grey.shade100, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), ), ), ); } } /// ========================== /// Dropdown Tile /// ========================== class DropdownTile extends StatelessWidget { final String title; final VoidCallback onTap; const DropdownTile({ required this.title, required this.onTap, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), decoration: _tileDecoration, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text(title, style: const TextStyle(fontSize: 14, color: Colors.black87), overflow: TextOverflow.ellipsis), ), const Icon(Icons.arrow_drop_down), ], ), ), ); } } /// ========================== /// Tile Container /// ========================== class TileContainer extends StatelessWidget { final Widget child; const TileContainer({required this.child, Key? key}) : super(key: key); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.all(14), decoration: _tileDecoration, child: child); } /// ========================== /// Attachments Section /// ========================== class AttachmentsSection extends StatelessWidget { final RxList attachments; final RxList> existingAttachments; final ValueChanged onRemoveNew; final ValueChanged>? onRemoveExisting; final VoidCallback onAdd; const AttachmentsSection({ required this.attachments, required this.existingAttachments, required this.onRemoveNew, this.onRemoveExisting, required this.onAdd, Key? key, }) : super(key: key); static const allowedImageExtensions = ['jpg', 'jpeg', 'png']; bool _isImageFile(File file) { final ext = file.path.split('.').last.toLowerCase(); return allowedImageExtensions.contains(ext); } @override Widget build(BuildContext context) { return Obx(() { final activeExisting = existingAttachments.where((doc) => doc['isActive'] != false).toList(); final imageFiles = attachments.where(_isImageFile).toList(); final imageExisting = activeExisting .where((d) => (d['contentType']?.toString().startsWith('image/') ?? false)) .toList(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (activeExisting.isNotEmpty) ...[ const Text("Existing Attachments", style: TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: activeExisting.map((doc) { final isImage = doc['contentType']?.toString().startsWith('image/') ?? false; final url = doc['url']; final fileName = doc['fileName'] ?? 'Unnamed'; return _buildExistingTile( context, doc, isImage, url, fileName, imageExisting, ); }).toList(), ), const SizedBox(height: 16), ], Wrap( spacing: 8, runSpacing: 8, children: [ ...attachments.map((file) => GestureDetector( onTap: () => _onNewTap(context, file, imageFiles), child: _AttachmentTile( file: file, onRemove: () => onRemoveNew(file), ), )), _buildActionTile(Icons.attach_file, onAdd), _buildActionTile(Icons.camera_alt, () => Get.find().pickFromCamera()), ], ), ], ); }); } /// helper for new file tap void _onNewTap(BuildContext context, File file, List imageFiles) { if (_isImageFile(file)) { showDialog( context: context, builder: (_) => ImageViewerDialog( imageSources: imageFiles, initialIndex: imageFiles.indexOf(file), ), ); } else { showAppSnackbar( title: 'Info', message: 'Preview for this file type is not supported.', type: SnackbarType.info, ); } } /// helper for existing file tile Widget _buildExistingTile( BuildContext context, Map doc, bool isImage, String? url, String fileName, List> imageExisting, ) { return Stack( clipBehavior: Clip.none, children: [ GestureDetector( onTap: () async { if (isImage) { final sources = imageExisting.map((e) => e['url']).toList(); final idx = imageExisting.indexOf(doc); showDialog( context: context, builder: (_) => ImageViewerDialog(imageSources: sources, initialIndex: idx), ); } else if (url != null && await canLaunchUrlString(url)) { await launchUrlString(url, mode: LaunchMode.externalApplication); } else { showAppSnackbar( title: 'Error', message: 'Could not open the document.', type: SnackbarType.error, ); } }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: _tileDecoration.copyWith( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(isImage ? Icons.image : Icons.insert_drive_file, size: 20, color: Colors.grey[600]), const SizedBox(width: 7), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 120), child: Text(fileName, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), ), ], ), ), ), if (onRemoveExisting != null) Positioned( top: -6, right: -6, child: IconButton( icon: const Icon(Icons.close, color: Colors.red, size: 18), onPressed: () => onRemoveExisting?.call(doc), ), ), ], ); } Widget _buildActionTile(IconData icon, VoidCallback onTap) => GestureDetector( onTap: onTap, child: Container( width: 50, height: 50, decoration: _tileDecoration.copyWith( border: Border.all(color: Colors.grey.shade400), ), child: Icon(icon, size: 30, color: Colors.grey), ), ); } /// ========================== /// Attachment Tile /// ========================== class _AttachmentTile extends StatelessWidget { final File file; final VoidCallback onRemove; const _AttachmentTile({required this.file, required this.onRemove}); @override Widget build(BuildContext context) { final fileName = file.path.split('/').last; final extension = fileName.split('.').last.toLowerCase(); final isImage = AttachmentsSection.allowedImageExtensions.contains(extension); final (icon, color) = _fileIcon(extension); return Stack( clipBehavior: Clip.none, children: [ Container( width: 80, height: 80, decoration: _tileDecoration, child: isImage ? ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.file(file, fit: BoxFit.cover), ) : Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: color, size: 30), const SizedBox(height: 4), Text(extension.toUpperCase(), style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: color)), ], ), ), Positioned( top: -6, right: -6, child: IconButton( icon: const Icon(Icons.close, color: Colors.red, size: 18), onPressed: onRemove, ), ), ], ); } /// map extensions to icons/colors static (IconData, Color) _fileIcon(String ext) { switch (ext) { case 'pdf': return (Icons.picture_as_pdf, Colors.redAccent); case 'doc': case 'docx': return (Icons.description, Colors.blueAccent); case 'xls': case 'xlsx': return (Icons.table_chart, Colors.green); case 'txt': return (Icons.article, Colors.grey); default: return (Icons.insert_drive_file, Colors.blueGrey); } } }