423 lines
12 KiB
Dart
423 lines
12 KiB
Dart
// 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<File> attachments;
|
|
final RxList<Map<String, dynamic>> existingAttachments;
|
|
final ValueChanged<File> onRemoveNew;
|
|
final ValueChanged<Map<String, dynamic>>? 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<AddExpenseController>().pickFromCamera()),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
/// helper for new file tap
|
|
void _onNewTap(BuildContext context, File file, List<File> 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<String, dynamic> doc,
|
|
bool isImage,
|
|
String? url,
|
|
String fileName,
|
|
List<Map<String, dynamic>> 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);
|
|
}
|
|
}
|
|
}
|