enrollment-app/lib/presentation/email_template/reminder_email_template_dialog.dart
2026-08-06 15:03:54 +05:30

645 lines
19 KiB
Dart

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:nhancepolicy/customAppBar/toastHelper.dart';
import 'package:nhancepolicy/logger.dart';
import 'package:nhancepolicy/presentation/email_template/email_html_preview.dart';
import 'package:nhancepolicy/presentation/email_template/email_template_models.dart';
import 'package:nhancepolicy/presentation/email_template/grapes_email_editor.dart';
import 'package:nhancepolicy/service/api_service.dart';
import 'package:nhancepolicy/service/token_storage_service.dart';
class ReminderEmailTemplateDialog extends StatefulWidget {
final String clientId;
final String clientPolicyId;
final String clientBranchId;
final String token;
final ApiService apiService;
final String defaultSubject;
final String defaultHtmlBody;
final ReminderEmailTemplateMode mode;
final Future<void> Function(String subject, String htmlBody)? onSend;
final bool embedded;
const ReminderEmailTemplateDialog({
super.key,
required this.clientId,
required this.clientPolicyId,
required this.clientBranchId,
required this.token,
required this.apiService,
required this.defaultSubject,
required this.defaultHtmlBody,
this.mode = ReminderEmailTemplateMode.config,
this.onSend,
this.embedded = false,
});
@override
State<ReminderEmailTemplateDialog> createState() =>
_ReminderEmailTemplateDialogState();
}
class _ReminderEmailTemplateDialogState extends State<ReminderEmailTemplateDialog> {
static const _teal = Color(0xFF009195);
final _templateNameController = TextEditingController();
final _subjectController = TextEditingController();
final _htmlEditController = TextEditingController();
final _grapesController = GrapesEmailEditorController();
bool _isLoading = true;
bool _isSubmitting = false;
bool _isHtmlEditMode = false;
int _editorGeneration = 0;
int? _templateId;
String _templateName = 'Enrollment Reminder Mail';
String _htmlBody = '';
List<EmailTemplatePlaceholder> _placeholders = const [];
@override
void initState() {
super.initState();
_loadTemplate();
}
@override
void dispose() {
_templateNameController.dispose();
_subjectController.dispose();
_htmlEditController.dispose();
super.dispose();
}
void _syncHtmlEditor() {
_htmlEditController.text = _htmlBody;
}
Future<void> _captureHtml() async {
if (_isHtmlEditMode) {
_htmlBody = _htmlEditController.text;
return;
}
if (_grapesController.isReady) {
_htmlBody = await _grapesController.exportHtml();
} else {
final latest = _grapesController.currentHtml;
if (latest.trim().isNotEmpty) {
_htmlBody = latest;
}
}
}
Future<void> _toggleHtmlEditMode() async {
if (_isHtmlEditMode) {
_htmlBody = _htmlEditController.text;
setState(() {
_isHtmlEditMode = false;
_editorGeneration++;
});
return;
}
await _captureHtml();
_syncHtmlEditor();
setState(() => _isHtmlEditMode = true);
}
void _insertPlaceholder(String token) {
if (_isHtmlEditMode) {
_insertInHtmlTextField('<span>$token</span>');
return;
}
_grapesController.insertToken(token);
}
void _insertInHtmlTextField(String snippet) {
final controller = _htmlEditController;
final text = controller.text;
final selection = controller.selection;
final start = selection.start >= 0 ? selection.start : text.length;
final end = selection.end >= 0 ? selection.end : text.length;
final updated = text.replaceRange(start, end, snippet);
controller.value = TextEditingValue(
text: updated,
selection: TextSelection.collapsed(offset: start + snippet.length),
);
setState(() => _htmlBody = updated);
}
Future<void> _loadTemplate() async {
setState(() => _isLoading = true);
try {
final response = await widget.apiService.getReminderMailConfigApi(
widget.clientPolicyId,
widget.token,
isEmailTemplate: true,
);
if (!mounted) return;
final ok = response['status'] == true || response['status'] == 'success';
final template = ok
? EmailTemplateData.fromApiResponse(
response,
defaultSubject: widget.defaultSubject,
defaultHtmlBody: widget.defaultHtmlBody,
)
: EmailTemplateData(
subject: widget.defaultSubject,
htmlBody: widget.defaultHtmlBody,
);
_templateId = template.templateId;
_templateName = template.templateName;
_templateNameController.text = template.templateName;
_subjectController.text = template.subject;
_htmlBody = template.htmlBody;
_placeholders = template.placeholders;
_syncHtmlEditor();
} catch (e) {
logDebug('Template load failed: $e');
_templateNameController.text = 'Enrollment Reminder Mail';
_subjectController.text = widget.defaultSubject;
_htmlBody = widget.defaultHtmlBody;
_placeholders = EmailTemplateData.defaultPlaceholders();
_syncHtmlEditor();
if (mounted) {
ToastHelper.showWarningToast(
context,
'Using default template. Submit to save changes.',
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
_editorGeneration++;
});
}
}
}
Future<int?> _resolveHrId() async {
final tokenService = TokenStorageService();
final hrId = await tokenService.readValue('enrollmentHrId') ??
await tokenService.readValue('empHrId');
return int.tryParse(hrId?.toString() ?? '');
}
Map<String, dynamic> _buildSavePayload() {
final policyId = int.tryParse(widget.clientPolicyId);
final payload = <String, dynamic>{
'client_id': widget.clientId,
'client_policy_id': policyId ?? widget.clientPolicyId,
'client_branch_id': widget.clientBranchId,
'email_subject': _subjectController.text.trim(),
'email_body': _htmlBody.trim(),
'template_name': _templateNameController.text.trim().isNotEmpty
? _templateNameController.text.trim()
: _templateName,
};
if (_templateId != null) {
payload['id'] = _templateId;
}
return payload;
}
Future<void> _submit() async {
await _captureHtml();
final subject = _subjectController.text.trim();
final htmlBody = _htmlBody.trim();
if (subject.isEmpty || htmlBody.isEmpty) {
if (mounted) {
ToastHelper.showWarningToast(
context,
'Subject and email body are required',
);
}
return;
}
if (widget.mode == ReminderEmailTemplateMode.send &&
widget.onSend != null) {
setState(() => _isSubmitting = true);
try {
await widget.onSend!(subject, htmlBody);
if (mounted) Navigator.pop(context);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
return;
}
setState(() => _isSubmitting = true);
try {
final payload = _buildSavePayload();
final hrId = await _resolveHrId();
if (hrId != null) {
payload['hr_id'] = hrId;
}
final response = await widget.apiService.saveEnrollmentReminderEmailTemplateApi(
widget.token,
payload,
);
if (!mounted) return;
final ok = response['status'] == true || response['status'] == 'success';
if (ok) {
final data = response['data'];
if (data is Map) {
final idRaw = data['id'] ?? data['template_id'];
_templateId = int.tryParse(idRaw?.toString() ?? '');
}
ToastHelper.showSuccessToast(
context,
response['message']?.toString() ?? 'Template saved successfully',
);
} else {
ToastHelper.showErrorToast(
context,
response['message']?.toString() ?? 'Failed to save template',
);
}
} catch (e) {
logDebug('Template save failed: $e');
if (mounted) {
ToastHelper.showErrorToast(
context,
'Failed to save template. Please try again.',
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
Widget _tealButton({
required String label,
required VoidCallback? onPressed,
bool loading = false,
double? width,
}) {
return SizedBox(
width: width,
height: 36,
child: ElevatedButton(
onPressed: loading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: _teal,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
label,
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
);
}
Future<void> _showPreviewMail() async {
await _captureHtml();
final previewHtml = _htmlBody;
if (previewHtml.trim().isEmpty) {
if (mounted) {
ToastHelper.showWarningToast(context, 'Email body is empty');
}
return;
}
if (!mounted) return;
showDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(24),
child: SizedBox(
width: MediaQuery.sizeOf(ctx).width * 0.7,
height: MediaQuery.sizeOf(ctx).height * 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Text(
'Preview Mail',
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(ctx),
icon: const Icon(Icons.close),
),
],
),
),
const Divider(height: 1),
Expanded(child: EmailHtmlPreview(html: previewHtml)),
],
),
),
),
);
}
Widget _webPointerGuard({required Widget child}) {
if (kIsWeb) {
return PointerInterceptor(child: child);
}
return child;
}
Widget _placeholdersMenu() {
final menuChild = Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
border: Border.all(
color: _placeholders.isEmpty
? const Color(0xFFE0E0E0)
: const Color(0xFFCCCCCC),
),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'PlaceHolders',
style: GoogleFonts.poppins(
fontSize: 12,
color: _placeholders.isEmpty ? Colors.black38 : Colors.black87,
),
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: _placeholders.isEmpty ? Colors.black26 : Colors.black87,
),
],
),
);
if (_placeholders.isEmpty) {
return _webPointerGuard(child: menuChild);
}
return _webPointerGuard(
child: PopupMenuButton<EmailTemplatePlaceholder>(
position: PopupMenuPosition.over,
itemBuilder: (context) => _placeholders
.map(
(p) => _InterceptorPopupMenuItem<EmailTemplatePlaceholder>(
value: p,
child: Text(
'${p.label} (${p.token})',
style: GoogleFonts.poppins(fontSize: 12),
),
),
)
.toList(),
onSelected: (p) => _insertPlaceholder(p.token),
child: menuChild,
),
);
}
Widget _buildEditorArea() {
if (_isHtmlEditMode) {
return Container(
margin: const EdgeInsets.fromLTRB(20, 8, 20, 0),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
padding: const EdgeInsets.all(8),
child: TextField(
controller: _htmlEditController,
maxLines: null,
expands: true,
style: GoogleFonts.robotoMono(fontSize: 12, color: Colors.white),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Edit HTML source...',
hintStyle: TextStyle(color: Colors.white38),
),
onChanged: (v) => _htmlBody = v,
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: GrapesEmailEditor(
key: ValueKey('grapes-editor-$_editorGeneration'),
html: _htmlBody,
controller: _grapesController,
onHtmlChanged: (html) => _htmlBody = html,
),
);
}
Widget _buildTemplateContent() {
final primaryLabel =
widget.mode == ReminderEmailTemplateMode.send ? 'Send' : 'Submit';
if (_isLoading) {
return const Center(child: CircularProgressIndicator(color: _teal));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.fromLTRB(20, widget.embedded ? 12 : 16, 12, 8),
child: Row(
children: [
Text(
'Template Name',
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _templateNameController,
style: GoogleFonts.poppins(fontSize: 14),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: const Color(0xFFF5F5F5),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide.none,
),
),
),
),
if (!widget.embedded)
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, size: 20),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Subject',
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _subjectController,
style: GoogleFonts.poppins(fontSize: 14),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: const Color(0xFFF5F5F5),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 8),
_placeholdersMenu(),
const SizedBox(width: 8),
_webPointerGuard(
child: _tealButton(
label: 'Preview Mail',
onPressed: () => _showPreviewMail(),
width: 120,
),
),
],
),
),
Expanded(child: _buildEditorArea()),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
children: [
_webPointerGuard(
child: TextButton.icon(
onPressed: () => _toggleHtmlEditMode(),
icon: Icon(
Icons.code,
size: 16,
color: _isHtmlEditMode ? _teal : Colors.black54,
),
label: Text(
_isHtmlEditMode ? 'Visual Editor' : 'Dev HTML',
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _isHtmlEditMode ? _teal : Colors.black54,
),
),
),
),
const Spacer(),
_webPointerGuard(
child: _tealButton(
label: primaryLabel,
onPressed: _submit,
loading: _isSubmitting,
width: 120,
),
),
],
),
),
],
);
}
@override
Widget build(BuildContext context) {
if (widget.embedded) {
return _buildTemplateContent();
}
final size = MediaQuery.sizeOf(context);
return Dialog(
insetPadding: const EdgeInsets.all(16),
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
child: SizedBox(
width: size.width * 0.95,
height: size.height * 0.92,
child: _buildTemplateContent(),
),
);
}
}
/// Popup menu items over [HtmlElementView] (GrapesJS iframe) need a
/// [PointerInterceptor] on web or the iframe steals pointer events.
class _InterceptorPopupMenuItem<T> extends PopupMenuItem<T> {
const _InterceptorPopupMenuItem({
required super.child,
super.value,
});
@override
PopupMenuItemState<T, _InterceptorPopupMenuItem<T>> createState() =>
_InterceptorPopupMenuItemState<T>();
}
class _InterceptorPopupMenuItemState<T>
extends PopupMenuItemState<T, _InterceptorPopupMenuItem<T>> {
@override
Widget build(BuildContext context) {
final item = super.build(context);
if (kIsWeb) {
return PointerInterceptor(child: item);
}
return item;
}
}