Email template
This commit is contained in:
parent
2120e997f0
commit
af571b914a
@ -50,8 +50,6 @@ class Environment {
|
||||
if (isProd) base = "Nhance HR"; // You can differentiate names if needed
|
||||
|
||||
switch (routeName) {
|
||||
case 'hrDashboard':
|
||||
return "$base - Insights";
|
||||
case 'claimsOverviewDashboard':
|
||||
return "$base - Dashboard";
|
||||
case 'policies':
|
||||
|
||||
@ -52,7 +52,7 @@ class _CustomAppBarState extends State<CustomAppBar> {
|
||||
prefs.setBool('showBackToHR', false);
|
||||
await prefs.remove('hrSelectArgumentsData');
|
||||
prefs.remove('token');
|
||||
Navigator.pushNamed(context, 'hrDashboard');
|
||||
Navigator.pushNamed(context, 'policies');
|
||||
} else {
|
||||
await prefs.clear();
|
||||
Navigator.pushNamed(context, 'phone');
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../service/token_storage_service.dart';
|
||||
|
||||
@ -29,6 +31,18 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<String> _getBranchSwitchRoute() async {
|
||||
final postRaw = await tokenStorage.readValue('empAllowed_modules');
|
||||
final postModules = postRaw != null && postRaw.isNotEmpty
|
||||
? List<int>.from(jsonDecode(postRaw))
|
||||
: <int>[];
|
||||
|
||||
if (postModules.isNotEmpty && postModules.contains(5)) {
|
||||
return 'claimsOverviewDashboard';
|
||||
}
|
||||
return 'policies';
|
||||
}
|
||||
|
||||
Future<void> _onBranchSelected(Map<String, dynamic> branch) async {
|
||||
final tokenStorage = TokenStorageService();
|
||||
|
||||
@ -36,9 +50,11 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 🔄 Refresh CURRENT PAGE only
|
||||
final route = ModalRoute.of(context)?.settings.name ?? 'hrDashboard';
|
||||
_loadBranches();
|
||||
|
||||
final route = await _getBranchSwitchRoute();
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pushReplacementNamed(context, route);
|
||||
}
|
||||
|
||||
|
||||
@ -366,7 +366,7 @@ class _MyEmailVerifyState extends State<MyEmailVerify> {
|
||||
if (_postToken != null && _postToken.isNotEmpty) {
|
||||
ToastHelper.showSuccessToast(context, 'Successfully Logged In');
|
||||
// if (emp_status == 'enrolled' || emp_status == 'active') {
|
||||
Navigator.pushReplacementNamed(context, 'hrDashboard');
|
||||
Navigator.pushReplacementNamed(context, 'policies');
|
||||
// } else {
|
||||
// Navigator.pushReplacementNamed(context, 'empDetails');
|
||||
// }
|
||||
@ -410,7 +410,7 @@ class _MyEmailVerifyState extends State<MyEmailVerify> {
|
||||
// if (emp_status == 'enrolled' || emp_status == 'active') {
|
||||
// Navigator.pushReplacementNamed(context, 'home');
|
||||
// } else {
|
||||
Navigator.pushReplacementNamed(context, 'hrDashboard');
|
||||
Navigator.pushReplacementNamed(context, 'policies');
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,6 @@ import 'package:nhancepolicy/presentation/policies.dart';
|
||||
import 'package:nhancepolicy/service/session/session_service.dart';
|
||||
import 'package:nhancepolicy/service/token_storage_service.dart';
|
||||
import 'package:nhancepolicy/home.dart';
|
||||
import 'package:nhancepolicy/presentation/hrDashboard.dart';
|
||||
import 'package:nhancepolicy/presentation/claims_overview/claims_overview_dashboard.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:nhancepolicy/hrLogin.dart';
|
||||
@ -267,7 +266,7 @@ final Map<String, WidgetBuilder> appRoutes = {
|
||||
'empDetails': (context) => empDetails(),
|
||||
'addOnsDetails': (context) => addOnsDetails(),
|
||||
'empReviewDetails': (context) => empReviewDetails(),
|
||||
'hrDashboard': (context) => hrDashboard(),
|
||||
'hrDashboard': (context) => policies(),
|
||||
'claimsOverviewDashboard': (context) => const ClaimsOverviewDashboard(),
|
||||
'hrPolicyDetails': (context) => hrPolicyDetails(
|
||||
ClientId: '',
|
||||
|
||||
@ -58,7 +58,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
||||
(Icons.people_outline, 'Demographics'),
|
||||
(Icons.location_city_outlined, 'Hospitals & Geography'),
|
||||
(Icons.medical_services_outlined, 'Specialty Analysis'),
|
||||
(Icons.group_add_outlined, 'Insurred'),
|
||||
(Icons.group_add_outlined, 'Insured'),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
@ -1026,7 +1026,7 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
|
||||
ClaimsOverviewTheme.pink,
|
||||
];
|
||||
|
||||
final donutHeight = widget.forPdfExport ? 190.0 : 300.0;
|
||||
final donutHeight = widget.forPdfExport ? 220.0 : 300.0;
|
||||
final trendHeight = widget.forPdfExport ? 228.0 : 320.0;
|
||||
final sectionGap = widget.forPdfExport ? 8.0 : 16.0;
|
||||
|
||||
@ -1235,19 +1235,16 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
|
||||
animationKey: _chartKey,
|
||||
);
|
||||
|
||||
if (forPdfExport) {
|
||||
return chart;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: chart),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(height: forPdfExport ? 4 : 8),
|
||||
_EnrollmentDonutLegend(
|
||||
labels: series.labels,
|
||||
values: series.values,
|
||||
colors: colors,
|
||||
totalLabel: totalLabel,
|
||||
compact: forPdfExport,
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -1259,20 +1256,23 @@ class _EnrollmentDonutLegend extends StatelessWidget {
|
||||
final List<double> values;
|
||||
final List<Color> colors;
|
||||
final String totalLabel;
|
||||
final bool compact;
|
||||
|
||||
const _EnrollmentDonutLegend({
|
||||
required this.labels,
|
||||
required this.values,
|
||||
required this.colors,
|
||||
required this.totalLabel,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final total = values.fold<double>(0, (a, b) => a + b);
|
||||
final fontSize = compact ? 9.0 : 11.0;
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
spacing: compact ? 8 : 12,
|
||||
runSpacing: compact ? 4 : 8,
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
@ -1282,18 +1282,18 @@ class _EnrollmentDonutLegend extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
width: compact ? 8 : 10,
|
||||
height: compact ? 8 : 10,
|
||||
decoration: BoxDecoration(
|
||||
color: colors[i % colors.length],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(width: compact ? 4 : 6),
|
||||
Text(
|
||||
'${labels[i]} ${pct.toStringAsFixed(1)}%',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11,
|
||||
fontSize: fontSize,
|
||||
color: ClaimsOverviewTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
@ -1303,7 +1303,7 @@ class _EnrollmentDonutLegend extends StatelessWidget {
|
||||
Text(
|
||||
'Total: $totalLabel',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 11,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ClaimsOverviewTheme.textPrimary,
|
||||
),
|
||||
|
||||
347
lib/presentation/email_template/email_html_preview.dart
Normal file
347
lib/presentation/email_template/email_html_preview.dart
Normal file
@ -0,0 +1,347 @@
|
||||
import 'dart:ui_web' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:nhancepolicy/presentation/email_template/email_template_models.dart';
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
|
||||
const _caretSelector = '[data-nhance-caret="1"]';
|
||||
|
||||
class EmailHtmlPreviewController {
|
||||
_EmailHtmlPreviewState? _state;
|
||||
|
||||
void _bind(_EmailHtmlPreviewState state) => _state = state;
|
||||
|
||||
void _unbind(_EmailHtmlPreviewState state) {
|
||||
if (_state == state) _state = null;
|
||||
}
|
||||
|
||||
bool get isReady => _state?.isReady ?? false;
|
||||
|
||||
bool get hasCaretMarker => _state?.hasCaretMarker ?? false;
|
||||
|
||||
String get currentHtml => _state?.currentHtml ?? '';
|
||||
|
||||
void removeCaretMarker() => _state?.removeCaretMarker();
|
||||
|
||||
bool insertSnippetAtCursor(String snippet) =>
|
||||
_state?.insertSnippetAtCursor(snippet) ?? false;
|
||||
}
|
||||
|
||||
class EmailHtmlPreview extends StatefulWidget {
|
||||
final String html;
|
||||
final double? height;
|
||||
final bool editable;
|
||||
final EmailHtmlPreviewController? controller;
|
||||
|
||||
const EmailHtmlPreview({
|
||||
super.key,
|
||||
required this.html,
|
||||
this.height,
|
||||
this.editable = false,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EmailHtmlPreview> createState() => _EmailHtmlPreviewState();
|
||||
}
|
||||
|
||||
class _EmailHtmlPreviewState extends State<EmailHtmlPreview> {
|
||||
late final String _viewType;
|
||||
html.IFrameElement? _iframe;
|
||||
late String _activeHtml;
|
||||
bool _iframeReady = false;
|
||||
bool _selectionListenersAttached = false;
|
||||
|
||||
bool get isReady => _iframeReady;
|
||||
|
||||
bool get hasCaretMarker {
|
||||
final doc = _iframeDocument;
|
||||
if (doc == null) return false;
|
||||
return doc.querySelector(_caretSelector) != null;
|
||||
}
|
||||
|
||||
html.Document? get _iframeDocument {
|
||||
final iframe = _iframe;
|
||||
if (iframe == null) return null;
|
||||
try {
|
||||
return (iframe as dynamic).contentDocument as html.Document?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
html.Element? get _iframeBody {
|
||||
final doc = _iframeDocument;
|
||||
if (doc == null) return null;
|
||||
return (doc as dynamic).body as html.Element?;
|
||||
}
|
||||
|
||||
dynamic get _iframeWindow {
|
||||
final iframe = _iframe;
|
||||
if (iframe == null) return null;
|
||||
return (iframe as dynamic).contentWindow;
|
||||
}
|
||||
|
||||
String get currentHtml {
|
||||
removeCaretMarker();
|
||||
try {
|
||||
final root = _iframeDocument?.documentElement;
|
||||
if (root != null) {
|
||||
return '<!DOCTYPE html>\n${root.outerHtml}';
|
||||
}
|
||||
} catch (_) {}
|
||||
return _stripCaretMarkers(_activeHtml);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeHtml = _stripCaretMarkers(widget.html);
|
||||
widget.controller?._bind(this);
|
||||
_viewType =
|
||||
'email-preview-${identityHashCode(this)}-${DateTime.now().microsecondsSinceEpoch}';
|
||||
if (kIsWeb) {
|
||||
_registerView();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller?._unbind(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _srcForIframe(String htmlContent) {
|
||||
final clean = _stripCaretMarkers(htmlContent);
|
||||
if (!widget.editable) return clean;
|
||||
return _injectEditableBody(clean);
|
||||
}
|
||||
|
||||
String _injectEditableBody(String htmlContent) {
|
||||
final bodyTag = RegExp(r'<body([^>]*)>', caseSensitive: false);
|
||||
if (!bodyTag.hasMatch(htmlContent)) return htmlContent;
|
||||
|
||||
return htmlContent.replaceFirstMapped(bodyTag, (match) {
|
||||
final attrs = match.group(1) ?? '';
|
||||
if (RegExp('contenteditable', caseSensitive: false).hasMatch(attrs)) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
return '<body$attrs contenteditable="true">';
|
||||
});
|
||||
}
|
||||
|
||||
String _stripCaretMarkers(String htmlContent) {
|
||||
return htmlContent.replaceAll(
|
||||
RegExp(
|
||||
r'<span[^>]*data-nhance-caret="1"[^>]*>.*?</span>',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
),
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
void _registerView() {
|
||||
ui.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
|
||||
final iframe = html.IFrameElement()
|
||||
..style.border = 'none'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.display = 'block'
|
||||
..style.pointerEvents = 'auto'
|
||||
..srcdoc = _srcForIframe(_activeHtml);
|
||||
|
||||
if (!widget.editable) {
|
||||
iframe.setAttribute('sandbox', 'allow-same-origin');
|
||||
}
|
||||
|
||||
iframe.onLoad.listen((_) {
|
||||
_iframeReady = true;
|
||||
_selectionListenersAttached = false;
|
||||
_setupEditableIframe();
|
||||
});
|
||||
|
||||
_iframe = iframe;
|
||||
return iframe;
|
||||
});
|
||||
}
|
||||
|
||||
void _setupEditableIframe() {
|
||||
if (!widget.editable || _selectionListenersAttached) return;
|
||||
final body = _iframeBody;
|
||||
if (body == null) return;
|
||||
|
||||
body.setAttribute('contenteditable', 'true');
|
||||
body.style.setProperty('outline', 'none');
|
||||
body.style.setProperty('cursor', 'text');
|
||||
|
||||
// Place an invisible DOM marker at the click position so inserts survive
|
||||
// focus moving to the sidebar.
|
||||
body.onMouseUp.listen((_) => _placeCaretMarker());
|
||||
body.onKeyUp.listen((_) => _placeCaretMarker());
|
||||
body.onClick.listen((_) => _placeCaretMarker());
|
||||
|
||||
_selectionListenersAttached = true;
|
||||
}
|
||||
|
||||
html.Element _createCaretMarker(html.Document doc) {
|
||||
final marker = doc.createElement('span');
|
||||
marker.setAttribute('data-nhance-caret', '1');
|
||||
marker.text = '\u200B';
|
||||
marker.style.setProperty('font-size', '0');
|
||||
marker.style.setProperty('line-height', '0');
|
||||
marker.style.setProperty('display', 'inline');
|
||||
return marker;
|
||||
}
|
||||
|
||||
void _placeCaretMarker() {
|
||||
if (!widget.editable || !_iframeReady) return;
|
||||
final doc = _iframeDocument;
|
||||
final body = _iframeBody;
|
||||
if (doc == null || body == null) return;
|
||||
|
||||
removeCaretMarker();
|
||||
|
||||
final range = _readSelectionRange(body);
|
||||
if (range == null) return;
|
||||
|
||||
try {
|
||||
final marker = _createCaretMarker(doc);
|
||||
range.insertNode(marker);
|
||||
(range as dynamic).setStartAfter(marker);
|
||||
(range as dynamic).collapse(true);
|
||||
|
||||
final win = _iframeWindow;
|
||||
final sel = win?.getSelection() as html.Selection?;
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
dynamic _readSelectionRange(html.Element body) {
|
||||
try {
|
||||
final win = _iframeWindow;
|
||||
final sel = win?.getSelection() as html.Selection?;
|
||||
if (sel == null || (sel.rangeCount ?? 0) == 0) return null;
|
||||
|
||||
final range = sel.getRangeAt(0);
|
||||
if (!_nodeInside(body, range.startContainer)) return null;
|
||||
|
||||
return (range as dynamic).cloneRange();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void removeCaretMarker() {
|
||||
final doc = _iframeDocument;
|
||||
if (doc == null) return;
|
||||
final markers = doc.querySelectorAll(_caretSelector).toList();
|
||||
for (final marker in markers) {
|
||||
marker.remove();
|
||||
}
|
||||
}
|
||||
|
||||
bool insertSnippetAtCursor(String snippet) {
|
||||
if (!widget.editable || !_iframeReady || snippet.trim().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final doc = _iframeDocument;
|
||||
if (doc == null) return false;
|
||||
|
||||
final marker = doc.querySelector(_caretSelector);
|
||||
if (marker == null || marker.parent == null) return false;
|
||||
|
||||
try {
|
||||
final parent = marker.parent!;
|
||||
final tempRange = doc.createRange();
|
||||
final fragment =
|
||||
(tempRange as dynamic).createContextualFragment(snippet);
|
||||
if (fragment == null) return false;
|
||||
|
||||
parent.insertBefore(fragment, marker);
|
||||
marker.remove();
|
||||
_activeHtml = currentHtml;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool _nodeInside(html.Node ancestor, html.Node node) {
|
||||
var current = node;
|
||||
while (true) {
|
||||
if (current == ancestor) return true;
|
||||
final parent = current.parentNode;
|
||||
if (parent == null) return false;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final preview = kIsWeb
|
||||
? ClipRect(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox.expand(
|
||||
child: HtmlElementView(viewType: _viewType),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
widget.editable
|
||||
? 'Editable email preview is available on web.'
|
||||
: 'Email preview is available on web.',
|
||||
style: GoogleFonts.poppins(fontSize: 13, color: Colors.black54),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final decorated = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: preview,
|
||||
);
|
||||
|
||||
if (widget.height != null) {
|
||||
return SizedBox(height: widget.height, child: decorated);
|
||||
}
|
||||
return decorated;
|
||||
}
|
||||
}
|
||||
|
||||
String appendHtmlSnippet(String html, String snippet) {
|
||||
if (html.trim().isEmpty) {
|
||||
return EmailTemplateData.wrapPlainTextAsHtml(snippet);
|
||||
}
|
||||
|
||||
final lower = html.toLowerCase();
|
||||
|
||||
const footerMarkers = [
|
||||
'<div class="footer"',
|
||||
"<div class='footer'",
|
||||
'<div class="footer "',
|
||||
];
|
||||
for (final marker in footerMarkers) {
|
||||
final idx = lower.indexOf(marker);
|
||||
if (idx != -1) {
|
||||
return '${html.substring(0, idx)}$snippet${html.substring(idx)}';
|
||||
}
|
||||
}
|
||||
|
||||
final bodyEnd = lower.lastIndexOf('</body>');
|
||||
if (bodyEnd == -1) {
|
||||
return '$html$snippet';
|
||||
}
|
||||
return '${html.substring(0, bodyEnd)}$snippet${html.substring(bodyEnd)}';
|
||||
}
|
||||
444
lib/presentation/email_template/email_template_models.dart
Normal file
444
lib/presentation/email_template/email_template_models.dart
Normal file
@ -0,0 +1,444 @@
|
||||
class EmailTemplateData {
|
||||
final String templateName;
|
||||
final String subject;
|
||||
final String htmlBody;
|
||||
final int? templateId;
|
||||
final List<EmailTemplatePlaceholder> placeholders;
|
||||
|
||||
const EmailTemplateData({
|
||||
this.templateName = 'Enrollment Reminder Mail',
|
||||
required this.subject,
|
||||
required this.htmlBody,
|
||||
this.templateId,
|
||||
this.placeholders = const [],
|
||||
});
|
||||
|
||||
static EmailTemplateData fromApiResponse(
|
||||
Map<String, dynamic> response, {
|
||||
required String defaultSubject,
|
||||
required String defaultHtmlBody,
|
||||
String defaultTemplateName = 'Enrollment Reminder Mail',
|
||||
}) {
|
||||
final data = response['data'];
|
||||
Map<String, dynamic> source;
|
||||
Map<String, dynamic>? parentData;
|
||||
if (data is Map) {
|
||||
parentData = Map<String, dynamic>.from(data);
|
||||
final nested = parentData['email_template'] ?? parentData['template'];
|
||||
source = nested is Map
|
||||
? Map<String, dynamic>.from(nested)
|
||||
: parentData;
|
||||
} else {
|
||||
source = Map<String, dynamic>.from(response);
|
||||
}
|
||||
|
||||
final templateName = _readString(source, const [
|
||||
'template_name',
|
||||
'name',
|
||||
'mail_template_name',
|
||||
]);
|
||||
final subject = _readString(source, const [
|
||||
'email_subject',
|
||||
'subject',
|
||||
'mail_subject',
|
||||
]);
|
||||
final htmlBody = _readString(source, const [
|
||||
'email_body',
|
||||
'email_html',
|
||||
'html_body',
|
||||
'body',
|
||||
'template_html',
|
||||
'mail_body',
|
||||
]);
|
||||
|
||||
final idRaw = source['id'] ?? source['template_id'];
|
||||
final templateId = idRaw == null ? null : int.tryParse(idRaw.toString());
|
||||
|
||||
final placeholders = parsePlaceholders(
|
||||
source['place_holders'] ??
|
||||
source['placeholders'] ??
|
||||
parentData?['place_holders'] ??
|
||||
parentData?['placeholders'],
|
||||
);
|
||||
|
||||
return EmailTemplateData(
|
||||
templateName:
|
||||
templateName.isNotEmpty ? templateName : defaultTemplateName,
|
||||
subject: subject.isNotEmpty ? subject : defaultSubject,
|
||||
htmlBody: htmlBody.isNotEmpty ? htmlBody : defaultHtmlBody,
|
||||
templateId: templateId,
|
||||
placeholders:
|
||||
placeholders.isNotEmpty ? placeholders : defaultPlaceholders(),
|
||||
);
|
||||
}
|
||||
|
||||
static List<EmailTemplatePlaceholder> defaultPlaceholders() {
|
||||
return parsePlaceholders(kDefaultEnrollmentReminderPlaceholderKeys);
|
||||
}
|
||||
|
||||
static List<EmailTemplatePlaceholder> parsePlaceholders(dynamic raw) {
|
||||
if (raw is! List || raw.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final placeholders = <EmailTemplatePlaceholder>[];
|
||||
for (final item in raw) {
|
||||
final key = item?.toString().trim();
|
||||
if (key == null || key.isEmpty) continue;
|
||||
placeholders.add(
|
||||
EmailTemplatePlaceholder(
|
||||
formatPlaceholderLabel(key),
|
||||
'{{$key}}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
static String formatPlaceholderLabel(String key) {
|
||||
return key
|
||||
.split('_')
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map(
|
||||
(part) =>
|
||||
'${part[0].toUpperCase()}${part.substring(1).toLowerCase()}',
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
static String _readString(Map<String, dynamic> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
if (value != null && value.toString().trim().isNotEmpty) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static String wrapPlainTextAsHtml(String text) {
|
||||
final escaped = text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('\n', '<br/>');
|
||||
return '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body style="font-family: Arial, Helvetica, sans-serif; line-height: 1.6; color: #333333; background-color: #ffffff; max-width: 500px; margin: 0 auto; padding: 24px; text-align: left;">
|
||||
<p>$escaped</p>
|
||||
</body>
|
||||
</html>''';
|
||||
}
|
||||
}
|
||||
|
||||
class EmailTemplatePlaceholder {
|
||||
final String label;
|
||||
final String token;
|
||||
|
||||
const EmailTemplatePlaceholder(this.label, this.token);
|
||||
}
|
||||
|
||||
const kDefaultEnrollmentReminderPlaceholderKeys = [
|
||||
'member_name',
|
||||
'nhance_logo',
|
||||
'client_logo',
|
||||
'policy_name',
|
||||
'policy_no',
|
||||
'emp_code',
|
||||
'app_link',
|
||||
'client_name',
|
||||
'enrollment_open_date',
|
||||
'enrollment_close_date',
|
||||
];
|
||||
|
||||
const kDefaultEnrollmentReminderHtmlBody = r'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Enrollment Reminder</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:Arial,Helvetica,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f6f8;padding:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background-color:#ffffff;border-radius:10px;overflow:hidden;">
|
||||
<tr>
|
||||
<td align="center" style="background-color:#009195;padding:24px;">
|
||||
<img src="{{client_logo}}" alt="Company Logo" width="160" style="display:block;margin:0 auto 12px;max-height:48px;width:auto;border:0;" />
|
||||
<h1 style="margin:0;font-size:22px;line-height:28px;color:#ffffff;font-weight:600;">
|
||||
Enrollment Reminder
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:32px 28px;color:#1f2937;font-size:15px;line-height:24px;">
|
||||
<p style="margin:0 0 16px;">
|
||||
Dear <strong>{{member_name}}</strong>,
|
||||
</p>
|
||||
<p style="margin:0 0 20px;">
|
||||
This is a reminder to complete your insurance enrollment for the policy below. Please finish enrollment before the deadline to avoid missing your benefits.
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;background-color:#f9fafb;margin-bottom:24px;">
|
||||
<tr>
|
||||
<td style="padding:16px 18px;font-size:14px;line-height:22px;color:#374151;">
|
||||
<strong style="color:#111827;">Policy Name:</strong> {{policy_name}}<br>
|
||||
<strong style="color:#111827;">Policy Number:</strong> {{policy_no}}<br>
|
||||
<strong style="color:#111827;">Employee Code:</strong> {{emp_code}}<br>
|
||||
<strong style="color:#111827;">Enrollment Open Date:</strong> {{enrollment_open_date}}<br>
|
||||
<strong style="color:#111827;">Enrollment Close Date:</strong> {{enrollment_close_date}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto 24px;">
|
||||
<tr>
|
||||
<td align="center" style="border-radius:6px;background-color:#009195;">
|
||||
<a href="{{app_link}}" target="_blank" style="display:inline-block;padding:14px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:6px;">
|
||||
Complete Enrollment
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 8px;">
|
||||
If you have already completed your enrollment, please ignore this email.
|
||||
</p>
|
||||
<p style="margin:0;">
|
||||
Regards,<br>
|
||||
<strong>{{client_name}}</strong>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding:18px 28px;background-color:#f9fafb;font-size:12px;line-height:18px;color:#6b7280;">
|
||||
This is an automated reminder email. Please do not reply directly.<br>
|
||||
<img src="{{nhance_logo}}" alt="Nhance" width="80" style="display:block;margin:12px auto 0;max-height:24px;width:auto;border:0;" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>''';
|
||||
|
||||
const kEmailContentBlocks = <({String label, String snippet})>[
|
||||
(
|
||||
label: 'Columns',
|
||||
snippet:
|
||||
'<table width="100%" cellpadding="0" cellspacing="0"><tr><td width="50%" style="padding:8px;vertical-align:top;">Column 1</td><td width="50%" style="padding:8px;vertical-align:top;">Column 2</td></tr></table>',
|
||||
),
|
||||
(
|
||||
label: 'Button',
|
||||
snippet:
|
||||
'<p style="text-align:center;margin:16px 0;"><a href="{{app_link}}" style="background:#009195;color:#fff;padding:12px 24px;text-decoration:none;border-radius:4px;display:inline-block;font-weight:600;">Click Here</a></p>',
|
||||
),
|
||||
(
|
||||
label: 'Divider',
|
||||
snippet:
|
||||
'<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;" />',
|
||||
),
|
||||
(
|
||||
label: 'Heading',
|
||||
snippet:
|
||||
'<h2 style="color:#009195;font-size:20px;margin:16px 0 8px;font-weight:600;">Heading</h2>',
|
||||
),
|
||||
(
|
||||
label: 'Paragraph',
|
||||
snippet:
|
||||
'<p style="margin:8px 0;line-height:1.6;font-size:14px;">Paragraph text</p>',
|
||||
),
|
||||
(
|
||||
label: 'Image',
|
||||
snippet:
|
||||
'<p style="text-align:center;margin:12px 0;"><img src="{{client_logo}}" alt="Logo" style="max-width:200px;height:auto;" /></p>',
|
||||
),
|
||||
(
|
||||
label: 'Social',
|
||||
snippet:
|
||||
'<p style="text-align:center;margin:12px 0;"><a href="#" style="margin:0 8px;color:#009195;">Facebook</a><a href="#" style="margin:0 8px;color:#009195;">LinkedIn</a></p>',
|
||||
),
|
||||
(
|
||||
label: 'Menu',
|
||||
snippet:
|
||||
'<p style="text-align:center;margin:12px 0;font-size:13px;"><a href="#" style="margin:0 8px;color:#009195;">Home</a>|<a href="#" style="margin:0 8px;color:#009195;">Support</a></p>',
|
||||
),
|
||||
(
|
||||
label: 'HTML',
|
||||
snippet: '<!-- custom html -->',
|
||||
),
|
||||
];
|
||||
|
||||
/// Column layout blocks (Unlayer Blocks > Blank tab).
|
||||
class EmailColumnLayout {
|
||||
final List<int> ratios;
|
||||
final String snippet;
|
||||
|
||||
const EmailColumnLayout({
|
||||
required this.ratios,
|
||||
required this.snippet,
|
||||
});
|
||||
}
|
||||
|
||||
const kEmailColumnLayouts = <EmailColumnLayout>[
|
||||
EmailColumnLayout(
|
||||
ratios: [100],
|
||||
snippet:
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td style="padding:8px;vertical-align:top;border:1px dashed #ccc;min-height:40px;"> </td></tr></table>',
|
||||
),
|
||||
EmailColumnLayout(
|
||||
ratios: [50, 50],
|
||||
snippet:
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="50%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td><td width="50%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td></tr></table>',
|
||||
),
|
||||
EmailColumnLayout(
|
||||
ratios: [33, 33, 33],
|
||||
snippet:
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="33%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td><td width="33%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td><td width="34%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td></tr></table>',
|
||||
),
|
||||
EmailColumnLayout(
|
||||
ratios: [25, 75],
|
||||
snippet:
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td><td width="75%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td></tr></table>',
|
||||
),
|
||||
EmailColumnLayout(
|
||||
ratios: [75, 25],
|
||||
snippet:
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="75%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td></tr></table>',
|
||||
),
|
||||
EmailColumnLayout(
|
||||
ratios: [25, 25, 25, 25],
|
||||
snippet:
|
||||
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;"> </td></tr></table>',
|
||||
),
|
||||
];
|
||||
|
||||
class EmailBodyStyle {
|
||||
final String backgroundColor;
|
||||
final String contentWidth;
|
||||
final String fontFamily;
|
||||
final String textColor;
|
||||
final String linkColor;
|
||||
final String contentAlignment;
|
||||
final String fontWeight;
|
||||
|
||||
const EmailBodyStyle({
|
||||
this.backgroundColor = '#ffffff',
|
||||
this.contentWidth = '500px',
|
||||
this.fontFamily = 'Arial, Helvetica, sans-serif',
|
||||
this.textColor = '#000000',
|
||||
this.linkColor = '#009195',
|
||||
this.contentAlignment = 'left',
|
||||
this.fontWeight = '400',
|
||||
});
|
||||
|
||||
int get contentWidthPx {
|
||||
final n = int.tryParse(contentWidth.replaceAll('px', '').trim());
|
||||
return n ?? 500;
|
||||
}
|
||||
|
||||
EmailBodyStyle copyWith({
|
||||
String? backgroundColor,
|
||||
String? contentWidth,
|
||||
String? fontFamily,
|
||||
String? textColor,
|
||||
String? linkColor,
|
||||
String? contentAlignment,
|
||||
String? fontWeight,
|
||||
}) {
|
||||
return EmailBodyStyle(
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
contentWidth: contentWidth ?? this.contentWidth,
|
||||
fontFamily: fontFamily ?? this.fontFamily,
|
||||
textColor: textColor ?? this.textColor,
|
||||
linkColor: linkColor ?? this.linkColor,
|
||||
contentAlignment: contentAlignment ?? this.contentAlignment,
|
||||
fontWeight: fontWeight ?? this.fontWeight,
|
||||
);
|
||||
}
|
||||
|
||||
static EmailBodyStyle parseFromHtml(String html) {
|
||||
final bgMatch = RegExp(
|
||||
r'background(?:-color)?:\s*([^;"]+)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html);
|
||||
final widthMatch = RegExp(
|
||||
r'max-width:\s*(\d+)px',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html);
|
||||
final fontMatch = RegExp(
|
||||
r'font-family:\s*([^;"]+)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html);
|
||||
final colorMatch = RegExp(
|
||||
r'(?<!background-)color:\s*([^;"]+)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html);
|
||||
final alignMatch = RegExp(
|
||||
r'text-align:\s*([^;"]+)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html);
|
||||
final weightMatch = RegExp(
|
||||
r'font-weight:\s*([^;"]+)',
|
||||
caseSensitive: false,
|
||||
).firstMatch(html);
|
||||
|
||||
return EmailBodyStyle(
|
||||
backgroundColor: bgMatch?.group(1)?.trim() ?? '#ffffff',
|
||||
contentWidth: '${widthMatch?.group(1)?.trim() ?? '500'}px',
|
||||
fontFamily: fontMatch?.group(1)?.trim() ?? 'Arial, Helvetica, sans-serif',
|
||||
textColor: colorMatch?.group(1)?.trim() ?? '#000000',
|
||||
contentAlignment: alignMatch?.group(1)?.trim() ?? 'left',
|
||||
fontWeight: weightMatch?.group(1)?.trim() ?? '400',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String applyEmailBodyStyles(String html, EmailBodyStyle style) {
|
||||
final bodyStyle =
|
||||
'font-family: ${style.fontFamily}; line-height: 1.6; color: ${style.textColor}; '
|
||||
'font-weight: ${style.fontWeight}; background-color: ${style.backgroundColor}; '
|
||||
'max-width: ${style.contentWidth}; margin: 0 auto; padding: 24px; '
|
||||
'text-align: ${style.contentAlignment};';
|
||||
|
||||
final lower = html.toLowerCase();
|
||||
final bodyStart = lower.indexOf('<body');
|
||||
if (bodyStart == -1) {
|
||||
return EmailTemplateData.wrapPlainTextAsHtml(html);
|
||||
}
|
||||
|
||||
final openEnd = html.indexOf('>', bodyStart);
|
||||
if (openEnd == -1) return html;
|
||||
|
||||
final bodyTag = html.substring(bodyStart, openEnd + 1);
|
||||
final hasStyle = bodyTag.toLowerCase().contains('style=');
|
||||
|
||||
String newBodyTag;
|
||||
if (hasStyle) {
|
||||
newBodyTag = bodyTag.replaceAll(
|
||||
RegExp(r'style="[^"]*"', caseSensitive: false),
|
||||
'style="$bodyStyle"',
|
||||
);
|
||||
} else {
|
||||
newBodyTag = bodyTag.replaceFirst('>', ' style="$bodyStyle">');
|
||||
}
|
||||
|
||||
var result = html.replaceRange(bodyStart, openEnd + 1, newBodyTag);
|
||||
|
||||
final linkStyle = '<style>a{color:${style.linkColor};}</style>';
|
||||
if (!result.toLowerCase().contains('<style>')) {
|
||||
final headEnd = result.toLowerCase().indexOf('</head>');
|
||||
if (headEnd != -1) {
|
||||
result =
|
||||
'${result.substring(0, headEnd)}$linkStyle${result.substring(headEnd)}';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
enum ReminderEmailTemplateMode { config, send }
|
||||
235
lib/presentation/email_template/grapes_email_editor.dart
Normal file
235
lib/presentation/email_template/grapes_email_editor.dart
Normal file
@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui_web' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
|
||||
class GrapesEmailEditorController {
|
||||
_GrapesEmailEditorState? _state;
|
||||
|
||||
void _bind(_GrapesEmailEditorState state) => _state = state;
|
||||
|
||||
void _unbind(_GrapesEmailEditorState state) {
|
||||
if (_state == state) _state = null;
|
||||
}
|
||||
|
||||
bool get isReady => _state?.isReady ?? false;
|
||||
|
||||
String get currentHtml => _state?.currentHtml ?? '';
|
||||
|
||||
Future<String> exportHtml() => _state?.exportHtml() ?? Future.value('');
|
||||
|
||||
void loadHtml(String html) => _state?.loadHtml(html);
|
||||
|
||||
void insertToken(String token) => _state?.insertToken(token);
|
||||
}
|
||||
|
||||
class GrapesEmailEditor extends StatefulWidget {
|
||||
final String html;
|
||||
final GrapesEmailEditorController? controller;
|
||||
final ValueChanged<String>? onHtmlChanged;
|
||||
|
||||
const GrapesEmailEditor({
|
||||
super.key,
|
||||
required this.html,
|
||||
this.controller,
|
||||
this.onHtmlChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GrapesEmailEditor> createState() => _GrapesEmailEditorState();
|
||||
}
|
||||
|
||||
class _GrapesEmailEditorState extends State<GrapesEmailEditor> {
|
||||
static int _instanceCounter = 0;
|
||||
|
||||
late final String _viewType;
|
||||
html.IFrameElement? _iframe;
|
||||
StreamSubscription<html.MessageEvent>? _messageSub;
|
||||
bool _isReady = false;
|
||||
bool _isDisposed = false;
|
||||
String _currentHtml = '';
|
||||
int _requestCounter = 0;
|
||||
final Map<int, Completer<String>> _pendingExports = {};
|
||||
|
||||
bool get isReady => _isReady;
|
||||
|
||||
String get currentHtml => _currentHtml;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentHtml = widget.html;
|
||||
widget.controller?._bind(this);
|
||||
_instanceCounter += 1;
|
||||
_viewType = 'grapes-email-editor-$_instanceCounter';
|
||||
if (kIsWeb) {
|
||||
_registerView();
|
||||
_messageSub = html.window.onMessage.listen(_onMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
_messageSub?.cancel();
|
||||
_messageSub = null;
|
||||
_pendingExports.clear();
|
||||
try {
|
||||
_postToEditor({'action': 'destroy'});
|
||||
_iframe?.src = 'about:blank';
|
||||
} catch (_) {}
|
||||
widget.controller?._unbind(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _registerView() {
|
||||
ui.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
|
||||
final iframe = html.IFrameElement()
|
||||
..src = 'email-editor.html'
|
||||
..style.border = 'none'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.display = 'block';
|
||||
|
||||
_iframe = iframe;
|
||||
return iframe;
|
||||
});
|
||||
}
|
||||
|
||||
void _postToEditor(Map<String, dynamic> payload) {
|
||||
if (_isDisposed) return;
|
||||
final win = _iframe?.contentWindow;
|
||||
if (win == null) return;
|
||||
try {
|
||||
final message = jsonEncode({
|
||||
'source': 'nhance-email-editor-host',
|
||||
...payload,
|
||||
});
|
||||
(win as dynamic).postMessage(message, '*');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _readPayload(Object? data) {
|
||||
if (data == null) return null;
|
||||
if (data is String) {
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (data is Map) {
|
||||
return Map<String, dynamic>.from(data);
|
||||
}
|
||||
try {
|
||||
final dynamic raw = data;
|
||||
final source = raw['source']?.toString();
|
||||
if (source == null) return null;
|
||||
return {
|
||||
'source': source,
|
||||
'action': raw['action']?.toString(),
|
||||
'html': raw['html']?.toString(),
|
||||
'requestId': raw['requestId'],
|
||||
'message': raw['message']?.toString(),
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onMessage(html.MessageEvent event) {
|
||||
if (_isDisposed || !mounted) return;
|
||||
|
||||
Map<String, dynamic>? data;
|
||||
try {
|
||||
data = _readPayload(event.data);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (data == null || data['source'] != 'nhance-email-editor') return;
|
||||
|
||||
try {
|
||||
switch (data['action']) {
|
||||
case 'ready':
|
||||
if (_isDisposed || !mounted) return;
|
||||
_isReady = true;
|
||||
loadHtml(_currentHtml);
|
||||
break;
|
||||
case 'change':
|
||||
if (_isDisposed || !mounted) return;
|
||||
final htmlContent = data['html']?.toString() ?? '';
|
||||
if (htmlContent.isEmpty || htmlContent == _currentHtml) return;
|
||||
_currentHtml = htmlContent;
|
||||
widget.onHtmlChanged?.call(htmlContent);
|
||||
break;
|
||||
case 'html':
|
||||
if (_isDisposed) return;
|
||||
final requestId = int.tryParse(data['requestId']?.toString() ?? '');
|
||||
if (requestId != null) {
|
||||
_pendingExports.remove(requestId)?.complete(
|
||||
data['html']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void loadHtml(String html) {
|
||||
if (_isDisposed) return;
|
||||
_currentHtml = html;
|
||||
_postToEditor({'action': 'load', 'html': html});
|
||||
}
|
||||
|
||||
Future<String> exportHtml() async {
|
||||
if (_isDisposed) return _currentHtml;
|
||||
if (!_isReady) return _currentHtml;
|
||||
final requestId = ++_requestCounter;
|
||||
final completer = Completer<String>();
|
||||
_pendingExports[requestId] = completer;
|
||||
_postToEditor({'action': 'export', 'requestId': requestId});
|
||||
try {
|
||||
final result = await completer.future.timeout(const Duration(seconds: 5));
|
||||
if (!_isDisposed) _currentHtml = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
return _currentHtml;
|
||||
} finally {
|
||||
_pendingExports.remove(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
void insertToken(String token) {
|
||||
_postToEditor({'action': 'insertToken', 'token': token});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!kIsWeb) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Drag-and-drop email editor is available on web.',
|
||||
style: GoogleFonts.poppins(fontSize: 13, color: Colors.black54),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ClipRect(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox.expand(
|
||||
child: HtmlElementView(viewType: _viewType),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,630 @@
|
||||
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;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final primaryLabel =
|
||||
widget.mode == ReminderEmailTemplateMode.send ? 'Send' : 'Submit';
|
||||
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: _isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: _teal))
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@ -27,7 +27,8 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import '../customAppBar/base_layout.dart';
|
||||
import '../customAppBar/customFooter.dart';
|
||||
import '../service/secure_pop_scope.dart';
|
||||
import 'hrDashboard.dart';
|
||||
import 'package:nhancepolicy/presentation/email_template/email_template_models.dart';
|
||||
import 'package:nhancepolicy/presentation/email_template/reminder_email_template_dialog.dart';
|
||||
import 'package:nhancepolicy/logger.dart';
|
||||
|
||||
class hrPolicyDetails extends StatefulWidget {
|
||||
@ -513,10 +514,34 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
}
|
||||
|
||||
bool _isEnrolledStatus(String status) =>
|
||||
status == 'under process' || status == 'enrolled';
|
||||
status == 'under process' ||
|
||||
status == 'enrolled' ||
|
||||
status == 'submitted';
|
||||
|
||||
String _getLoggedInValue(Map<String, dynamic> row) {
|
||||
final loggedInRaw = row['logged_in'] ?? row['emp_is_active'];
|
||||
final value = loggedInRaw?.toString().toLowerCase().trim() ?? '';
|
||||
|
||||
// API can return: null / Yes / No / 1 / 0 / true / false.
|
||||
if (value.isEmpty || value == 'null' || value == 'no' || value == '0' || value == 'false') {
|
||||
return 'no';
|
||||
}
|
||||
return (value == 'yes' || value == '1' || value == 'true') ? 'yes' : 'no';
|
||||
}
|
||||
|
||||
bool _isLoggedInRow(Map<String, dynamic> row) =>
|
||||
row['emp_is_active']?.toString() == '1';
|
||||
_getLoggedInValue(row) == 'yes';
|
||||
|
||||
bool _isSelfRow(Map<String, dynamic> row) =>
|
||||
row['relationship']?.toString().toLowerCase().trim() == 'self';
|
||||
|
||||
String _getLoggedInDisplayLabel(Map<String, dynamic> row) =>
|
||||
_getLoggedInValue(row) == 'yes' ? 'Yes' : 'No';
|
||||
|
||||
Color _getLoggedInChipColor(Map<String, dynamic> row) =>
|
||||
_getLoggedInValue(row) == 'yes'
|
||||
? const Color(0xFFBDF9D9)
|
||||
: const Color(0xFFE8EAF6);
|
||||
|
||||
bool _matchesPreStatusFilter(Map<String, dynamic> row, String filter) {
|
||||
final status = row['status']?.toString().toLowerCase().trim() ?? '';
|
||||
@ -530,9 +555,9 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
case 'not_enrolled':
|
||||
return !_isEnrolledStatus(status);
|
||||
case 'logged_in':
|
||||
return _isLoggedInRow(row);
|
||||
return _isSelfRow(row) && _isLoggedInRow(row);
|
||||
case 'not_logged_in':
|
||||
return !_isLoggedInRow(row);
|
||||
return _isSelfRow(row) && !_isLoggedInRow(row);
|
||||
case 'draft':
|
||||
return status == 'draft';
|
||||
default:
|
||||
@ -958,7 +983,9 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
}
|
||||
|
||||
Future<void> _openReminderConfigDialog() async {
|
||||
final clientId = localClientId ?? widget.ClientId;
|
||||
final clientPolicyId = localClientPolicyId ?? widget.ClientPoliyId;
|
||||
final clientBranchId = localClientBranchId ?? widget.clientBranchId;
|
||||
final token = localToken ?? widget.Token;
|
||||
|
||||
await showDialog<void>(
|
||||
@ -966,9 +993,13 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return _ReminderMailConfigDialog(
|
||||
clientId: clientId,
|
||||
clientPolicyId: clientPolicyId,
|
||||
clientBranchId: clientBranchId,
|
||||
token: token,
|
||||
apiService: apiService,
|
||||
defaultSubject: _defaultReminderSubject(),
|
||||
defaultHtmlBody: _defaultReminderHtmlBody(),
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -979,193 +1010,36 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
||||
return 'Reminder: Complete Your Enrollment - $policyName';
|
||||
}
|
||||
|
||||
String _defaultReminderBody() {
|
||||
final policyName = localCardPolicyName ?? widget.cardPolicy_name;
|
||||
final policyNo = localCardPolicyNo ?? widget.cardPolicyNo;
|
||||
return '''Dear Employee,
|
||||
|
||||
This is a gentle reminder to complete your insurance enrollment for $policyName ($policyNo).
|
||||
|
||||
Please log in to the Nhance portal and complete your enrollment at the earliest.
|
||||
|
||||
If you have already completed enrollment, please ignore this email.
|
||||
|
||||
Regards,
|
||||
HR Team''';
|
||||
}
|
||||
|
||||
Future<({String subject, String body})> _loadReminderEmailTemplate() async {
|
||||
var subject = _defaultReminderSubject();
|
||||
var body = _defaultReminderBody();
|
||||
|
||||
try {
|
||||
final response = await apiService.getEnrollmentReminderEmailTemplateApi(
|
||||
localClientId ?? widget.ClientId,
|
||||
localClientPolicyId ?? widget.ClientPoliyId,
|
||||
localClientBranchId ?? widget.clientBranchId,
|
||||
localToken ?? widget.Token,
|
||||
);
|
||||
|
||||
if (response['status'] == 'success' || response['status'] == true) {
|
||||
final data = response['data'];
|
||||
if (data is Map<String, dynamic>) {
|
||||
subject = data['email_subject']?.toString().trim().isNotEmpty == true
|
||||
? data['email_subject'].toString()
|
||||
: data['subject']?.toString().trim().isNotEmpty == true
|
||||
? data['subject'].toString()
|
||||
: subject;
|
||||
body = data['email_body']?.toString().trim().isNotEmpty == true
|
||||
? data['email_body'].toString()
|
||||
: data['body']?.toString().trim().isNotEmpty == true
|
||||
? data['body'].toString()
|
||||
: body;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logDebug('Reminder template fetch failed, using default: $e');
|
||||
}
|
||||
|
||||
return (subject: subject, body: body);
|
||||
String _defaultReminderHtmlBody() {
|
||||
return kDefaultEnrollmentReminderHtmlBody;
|
||||
}
|
||||
|
||||
Future<void> _openReminderTemplateDialog() async {
|
||||
final template = await _loadReminderEmailTemplate();
|
||||
if (!mounted) return;
|
||||
final clientId = localClientId ?? widget.ClientId;
|
||||
final clientPolicyId = localClientPolicyId ?? widget.ClientPoliyId;
|
||||
final clientBranchId = localClientBranchId ?? widget.clientBranchId;
|
||||
final token = localToken ?? widget.Token;
|
||||
|
||||
final subjectController = TextEditingController(text: template.subject);
|
||||
final bodyController = TextEditingController(text: template.body);
|
||||
|
||||
final result = await showDialog<Map<String, String>?>(
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
return ReminderEmailTemplateDialog(
|
||||
clientId: clientId,
|
||||
clientPolicyId: clientPolicyId,
|
||||
clientBranchId: clientBranchId,
|
||||
token: token,
|
||||
apiService: apiService,
|
||||
defaultSubject: _defaultReminderSubject(),
|
||||
defaultHtmlBody: _defaultReminderHtmlBody(),
|
||||
mode: ReminderEmailTemplateMode.send,
|
||||
onSend: (subject, htmlBody) => _sendEnrollmentReminder(
|
||||
emailSubject: subject,
|
||||
emailBody: htmlBody,
|
||||
),
|
||||
title: Text(
|
||||
'Send Reminder Email',
|
||||
style: GoogleFonts.poppins(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 620,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Review and edit the email template before sending to employees who have not completed enrollment.',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Subject',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: subjectController,
|
||||
style: GoogleFonts.poppins(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Email subject',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF5F5F5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Email Body',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: bodyController,
|
||||
minLines: 10,
|
||||
maxLines: 14,
|
||||
style: GoogleFonts.poppins(fontSize: 14, height: 1.45),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Email body',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF5F5F5),
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: GoogleFonts.poppins(color: Colors.black54),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final subject = subjectController.text.trim();
|
||||
final body = bodyController.text.trim();
|
||||
if (subject.isEmpty || body.isEmpty) {
|
||||
ToastHelper.showWarningToast(
|
||||
dialogContext,
|
||||
'Subject and email body are required',
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.pop(dialogContext, {
|
||||
'subject': subject,
|
||||
'body': body,
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFE26728),
|
||||
),
|
||||
child: Text(
|
||||
'Send',
|
||||
style: GoogleFonts.poppins(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
subjectController.dispose();
|
||||
bodyController.dispose();
|
||||
|
||||
if (result != null) {
|
||||
await _sendEnrollmentReminder(
|
||||
emailSubject: result['subject']!,
|
||||
emailBody: result['body']!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendEnrollmentReminder({
|
||||
@ -1176,7 +1050,7 @@ HR Team''';
|
||||
|
||||
try {
|
||||
final hrId = await tokenService.readValue('enrollmentHrId');
|
||||
final response = await apiService.sendEnrollmentReminderApi(
|
||||
final response = await apiService.sendReminderMailApi(
|
||||
localClientId ?? widget.ClientId,
|
||||
localClientPolicyId ?? widget.ClientPoliyId,
|
||||
localClientBranchId ?? widget.clientBranchId,
|
||||
@ -1383,28 +1257,6 @@ HR Team''';
|
||||
/// Push right content to end
|
||||
const Spacer(),
|
||||
|
||||
/// 🔍 Search Box
|
||||
Container(
|
||||
width: 380,
|
||||
height: 37,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F0F0),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
onChanged: search,
|
||||
style: GoogleFonts.poppins(fontSize: 14),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search',
|
||||
prefixIcon: Icon(Icons.search, size: 18),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (localIsEcardBulkDownload == 1) ...[
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
@ -1687,7 +1539,7 @@ HR Team''';
|
||||
// ---------------- PREMIUM / STATUS ----------------
|
||||
if (localTokenType == "pre")
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 15),
|
||||
padding: const EdgeInsets.only(left: 15, right: 16),
|
||||
child: _buildStatusSummary(),
|
||||
),
|
||||
|
||||
@ -1695,6 +1547,7 @@ HR Team''';
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@ -1712,6 +1565,8 @@ HR Team''';
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildCompactSearchField(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -2069,10 +1924,12 @@ HR Team''';
|
||||
} else {
|
||||
notEnrolled++;
|
||||
}
|
||||
if (_isLoggedInRow(item)) {
|
||||
loggedIn++;
|
||||
} else {
|
||||
notLoggedIn++;
|
||||
if (_isSelfRow(item)) {
|
||||
if (_isLoggedInRow(item)) {
|
||||
loggedIn++;
|
||||
} else {
|
||||
notLoggedIn++;
|
||||
}
|
||||
}
|
||||
if (status == 'draft') draft++;
|
||||
}
|
||||
@ -2106,6 +1963,37 @@ HR Team''';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCompactSearchField({double width = 180}) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: 32,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F0F0),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
onChanged: search,
|
||||
textAlign: TextAlign.left,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: GoogleFonts.poppins(fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search',
|
||||
hintStyle: GoogleFonts.poppins(fontSize: 12, color: Colors.black45),
|
||||
prefixIcon: const Icon(Icons.search, size: 16),
|
||||
prefixIconConstraints:
|
||||
const BoxConstraints(minWidth: 28, minHeight: 32),
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding:
|
||||
const EdgeInsets.only(left: 0, right: 10, top: 0, bottom: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreStatusFilterChip({
|
||||
required String filterKey,
|
||||
required String label,
|
||||
@ -2158,51 +2046,63 @@ HR Team''';
|
||||
|
||||
return SizedBox(
|
||||
height: 38,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: filters.length + 1,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == filters.length) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _preStatusFilter == null ? null : _resetPreStatusFilter,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F0F0),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: _preStatusFilter != null
|
||||
? const Color(0xFF009195)
|
||||
: const Color(0xFFD0D0D0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: filters.length + 1,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == filters.length) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap:
|
||||
_preStatusFilter == null ? null : _resetPreStatusFilter,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F0F0),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: _preStatusFilter != null
|
||||
? const Color(0xFF009195)
|
||||
: const Color(0xFFD0D0D0),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Reset',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _preStatusFilter != null
|
||||
? const Color(0xFF009195)
|
||||
: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Reset',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _preStatusFilter != null
|
||||
? const Color(0xFF009195)
|
||||
: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
final filter = filters[index];
|
||||
return _buildPreStatusFilterChip(
|
||||
filterKey: filter['key']!,
|
||||
label: filter['label']!,
|
||||
count: statusCounts[filter['key']] ?? 0,
|
||||
);
|
||||
},
|
||||
final filter = filters[index];
|
||||
return _buildPreStatusFilterChip(
|
||||
filterKey: filter['key']!,
|
||||
label: filter['label']!,
|
||||
count: statusCounts[filter['key']] ?? 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_buildCompactSearchField(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -2221,6 +2121,7 @@ HR Team''';
|
||||
pinned: true,
|
||||
delegate: _CDHeaderDelegate(
|
||||
showUHID: localPolicyTypeId != '6' && localPolicyTypeId != '7',
|
||||
showLoggedIn: localTokenType == "pre",
|
||||
showAction:
|
||||
localTokenType != "pre" && (hasAnyEcardLink || hasModule),
|
||||
),
|
||||
@ -2302,6 +2203,30 @@ HR Team''';
|
||||
child: Text(item['email_corporate'] ?? '-', style: _dataBold),
|
||||
),
|
||||
|
||||
if (localTokenType == "pre")
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _isSelfRow(item)
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getLoggedInChipColor(item),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
_getLoggedInDisplayLabel(item),
|
||||
textAlign: TextAlign.center,
|
||||
style: _dataBold,
|
||||
),
|
||||
)
|
||||
: Text('-', style: _dataBold),
|
||||
),
|
||||
|
||||
if (localTokenType == "pre") const SizedBox(width: 8),
|
||||
|
||||
/// STATUS
|
||||
Expanded(
|
||||
flex: 2,
|
||||
@ -3236,10 +3161,12 @@ HR Team''';
|
||||
|
||||
class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
final bool showUHID;
|
||||
final bool showLoggedIn;
|
||||
final bool showAction;
|
||||
|
||||
_CDHeaderDelegate({
|
||||
required this.showUHID,
|
||||
required this.showLoggedIn,
|
||||
required this.showAction,
|
||||
});
|
||||
|
||||
@ -3265,6 +3192,8 @@ class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
_headerCell('Gender', 2),
|
||||
_headerCell('Mobile', 2),
|
||||
_headerCell('Email', 5),
|
||||
if (showLoggedIn) _headerCell('Logged In', 2),
|
||||
if (showLoggedIn) const SizedBox(width: 8),
|
||||
_headerCell('Status', 2),
|
||||
if (showAction) _headerCell('Action', 3, center: true),
|
||||
],
|
||||
@ -3368,14 +3297,22 @@ class _PolicyTermsDialog extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _ReminderMailConfigDialog extends StatefulWidget {
|
||||
final String clientId;
|
||||
final String clientPolicyId;
|
||||
final String clientBranchId;
|
||||
final String token;
|
||||
final ApiService apiService;
|
||||
final String defaultSubject;
|
||||
final String defaultHtmlBody;
|
||||
|
||||
const _ReminderMailConfigDialog({
|
||||
required this.clientId,
|
||||
required this.clientPolicyId,
|
||||
required this.clientBranchId,
|
||||
required this.token,
|
||||
required this.apiService,
|
||||
required this.defaultSubject,
|
||||
required this.defaultHtmlBody,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -3736,6 +3673,25 @@ class _ReminderMailConfigDialogState extends State<_ReminderMailConfigDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openTemplateEditor() async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return ReminderEmailTemplateDialog(
|
||||
clientId: widget.clientId,
|
||||
clientPolicyId: widget.clientPolicyId,
|
||||
clientBranchId: widget.clientBranchId,
|
||||
token: widget.token,
|
||||
apiService: widget.apiService,
|
||||
defaultSubject: widget.defaultSubject,
|
||||
defaultHtmlBody: widget.defaultHtmlBody,
|
||||
mode: ReminderEmailTemplateMode.config,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBusy = _isSaving;
|
||||
@ -3830,6 +3786,26 @@ class _ReminderMailConfigDialogState extends State<_ReminderMailConfigDialog> {
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildDaysField(),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
onPressed: isBusy ? null : _openTemplateEditor,
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
label: Text(
|
||||
'Edit Email Template',
|
||||
style: GoogleFonts.poppins(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF009195),
|
||||
side: const BorderSide(color: Color(0xFF009195)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -1114,6 +1114,50 @@ class ApiService {
|
||||
return _makeGetRequest(url, headers);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> saveEnrollmentReminderEmailTemplateApi(
|
||||
String token,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final url =
|
||||
Uri.parse('${Environment.apiUrl}saveEnrollmentReminderEmailTemplate');
|
||||
final headers = {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
'APP-SIGNATURE':
|
||||
'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
|
||||
};
|
||||
return _makePostRequest(url, jsonEncode(payload), headers);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> sendTestEnrollmentReminderEmailApi(
|
||||
String clientId,
|
||||
String clientPolicyId,
|
||||
String clientBranchId,
|
||||
String hrId,
|
||||
String token, {
|
||||
required String emailSubject,
|
||||
required String emailBody,
|
||||
}) async {
|
||||
final url =
|
||||
Uri.parse('${Environment.apiUrl}testEnrollmentReminderEmail');
|
||||
final headers = {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
'APP-SIGNATURE':
|
||||
'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
|
||||
};
|
||||
final policyId = int.tryParse(clientPolicyId) ?? clientPolicyId;
|
||||
final body = {
|
||||
'client_id': clientId,
|
||||
'client_policy_id': policyId,
|
||||
'client_branch_id': clientBranchId,
|
||||
'hr_id': hrId,
|
||||
'email_subject': emailSubject,
|
||||
'email_body': emailBody,
|
||||
};
|
||||
return _makePostRequest(url, jsonEncode(body), headers);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> sendEnrollmentReminderApi(
|
||||
String clientId,
|
||||
String clientPolicyId,
|
||||
@ -1157,12 +1201,17 @@ class ApiService {
|
||||
|
||||
Future<Map<String, dynamic>> getReminderMailConfigApi(
|
||||
String clientPolicyId,
|
||||
String token,
|
||||
) async {
|
||||
final url = Uri.parse(
|
||||
String token, {
|
||||
bool isEmailTemplate = false,
|
||||
}) async {
|
||||
final query = StringBuffer(
|
||||
'${Environment.apiUrl}getReminderMailConfig'
|
||||
'?client_policy_id=$clientPolicyId',
|
||||
);
|
||||
if (isEmailTemplate) {
|
||||
query.write('&is_email_template=true');
|
||||
}
|
||||
final url = Uri.parse(query.toString());
|
||||
final headers = {
|
||||
'Authorization': 'Bearer $token',
|
||||
};
|
||||
@ -1182,20 +1231,31 @@ class ApiService {
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> sendReminderMailApi(
|
||||
String clientId,
|
||||
String clientPolicyId,
|
||||
String token,
|
||||
) async {
|
||||
String clientBranchId,
|
||||
String hrId,
|
||||
String token, {
|
||||
required String emailSubject,
|
||||
required String emailBody,
|
||||
}) async {
|
||||
final url = Uri.parse('${Environment.apiUrl}sendReminderMail');
|
||||
final headers = {
|
||||
'Authorization': 'Bearer $token',
|
||||
'APP-SIGNATURE':
|
||||
'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
final policyId = int.tryParse(clientPolicyId) ?? clientPolicyId;
|
||||
return _makePostRequest(
|
||||
url,
|
||||
jsonEncode({'client_policy_id': policyId}),
|
||||
headers,
|
||||
);
|
||||
final body = {
|
||||
'client_id': clientId,
|
||||
'client_policy_id': policyId,
|
||||
'client_branch_id': clientBranchId,
|
||||
'hr_id': hrId,
|
||||
'email_subject': emailSubject,
|
||||
'email_body': emailBody,
|
||||
};
|
||||
return _makePostRequest(url, jsonEncode(body), headers);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getClientRMApi(
|
||||
|
||||
@ -64,6 +64,7 @@ dependencies:
|
||||
dropdown_button2: ^2.3.9
|
||||
fl_chart: ^1.2.0
|
||||
image: ^4.3.0
|
||||
pointer_interceptor: ^0.10.1+2
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
194
web/email-editor.html
Normal file
194
web/email-editor.html
Normal file
@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Email Editor</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/grapesjs/dist/css/grapes.min.css" />
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
#gjs { height: 100%; border: none; }
|
||||
.gjs-one-bg { background-color: #f8f9fa; }
|
||||
.gjs-two-color { color: #333; }
|
||||
.gjs-three-bg { background-color: #009195; color: #fff; }
|
||||
.gjs-four-color,
|
||||
.gjs-four-color-h:hover { color: #009195; }
|
||||
.gjs-pn-btn.gjs-pn-active,
|
||||
.gjs-pn-btn:hover { color: #009195; }
|
||||
.gjs-block { min-height: 54px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="gjs"></div>
|
||||
|
||||
<script src="https://unpkg.com/grapesjs"></script>
|
||||
<script src="https://unpkg.com/grapesjs-preset-newsletter"></script>
|
||||
<script>
|
||||
let editor = null;
|
||||
let pendingHtml = null;
|
||||
let changeTimer = null;
|
||||
let lastExportedHtml = '';
|
||||
let destroyed = false;
|
||||
|
||||
function postToParent(payload) {
|
||||
if (destroyed) return;
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(JSON.stringify(payload), '*');
|
||||
}
|
||||
}
|
||||
|
||||
function extractBodyContent(html) {
|
||||
if (!html) return '';
|
||||
const match = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
return match ? match[1].trim() : html;
|
||||
}
|
||||
|
||||
function wrapAsEmailDocument(innerHtml) {
|
||||
return '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n</head>\n<body style="margin:0;padding:0;">' + innerHtml + '\n</body>\n</html>';
|
||||
}
|
||||
|
||||
function exportFullHtml() {
|
||||
if (!editor) return '';
|
||||
try {
|
||||
const inlined = editor.runCommand('gjs-get-inlined-html');
|
||||
return wrapAsEmailDocument(inlined || '');
|
||||
} catch (e) {
|
||||
const html = editor.getHtml() || '';
|
||||
const css = editor.getCss() || '';
|
||||
return wrapAsEmailDocument('<style>' + css + '</style>' + html);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleChange() {
|
||||
if (destroyed) return;
|
||||
clearTimeout(changeTimer);
|
||||
changeTimer = setTimeout(function () {
|
||||
if (destroyed || !editor) return;
|
||||
const html = exportFullHtml();
|
||||
if (!html || html === lastExportedHtml) return;
|
||||
lastExportedHtml = html;
|
||||
postToParent({ source: 'nhance-email-editor', action: 'change', html: html });
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function loadHtml(fullHtml) {
|
||||
if (destroyed || !editor) {
|
||||
pendingHtml = fullHtml;
|
||||
return;
|
||||
}
|
||||
const content = extractBodyContent(fullHtml);
|
||||
editor.setComponents(content);
|
||||
editor.refresh();
|
||||
lastExportedHtml = exportFullHtml();
|
||||
}
|
||||
|
||||
function insertToken(token) {
|
||||
if (destroyed || !editor) return;
|
||||
const selected = editor.getSelected();
|
||||
const cmp = selected && selected.get('type') !== 'wrapper'
|
||||
? selected
|
||||
: editor.getWrapper().components().at(0);
|
||||
if (cmp && cmp.append) {
|
||||
cmp.append('<span>' + token + '</span>');
|
||||
} else {
|
||||
editor.addComponents('<span>' + token + '</span>');
|
||||
}
|
||||
scheduleChange();
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
destroyed = true;
|
||||
clearTimeout(changeTimer);
|
||||
changeTimer = null;
|
||||
try {
|
||||
if (editor) editor.destroy();
|
||||
} catch (e) {}
|
||||
editor = null;
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
try {
|
||||
editor = grapesjs.init({
|
||||
container: '#gjs',
|
||||
height: '100%',
|
||||
width: 'auto',
|
||||
fromElement: false,
|
||||
storageManager: false,
|
||||
noticeOnUnload: false,
|
||||
canvas: {
|
||||
styles: ['https://fonts.googleapis.com/css?family=Roboto:400,500,700'],
|
||||
},
|
||||
deviceManager: {
|
||||
devices: [
|
||||
{ name: 'Desktop', width: '' },
|
||||
{ name: 'Mobile', width: '320px', widthMedia: '480px' },
|
||||
],
|
||||
},
|
||||
plugins: ['grapesjs-preset-newsletter'],
|
||||
pluginsOpts: {
|
||||
'grapesjs-preset-newsletter': {
|
||||
modalLabelImport: 'Paste your HTML',
|
||||
modalLabelExport: 'Copy HTML',
|
||||
codeViewerTheme: 'material',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
editor.on('update', scheduleChange);
|
||||
|
||||
if (pendingHtml) {
|
||||
loadHtml(pendingHtml);
|
||||
pendingHtml = null;
|
||||
}
|
||||
|
||||
postToParent({ source: 'nhance-email-editor', action: 'ready' });
|
||||
} catch (e) {
|
||||
postToParent({ source: 'nhance-email-editor', action: 'error', message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
let data = event.data || {};
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (data.source !== 'nhance-email-editor-host') return;
|
||||
|
||||
switch (data.action) {
|
||||
case 'load':
|
||||
loadHtml(data.html || '');
|
||||
break;
|
||||
case 'export':
|
||||
postToParent({
|
||||
source: 'nhance-email-editor',
|
||||
action: 'html',
|
||||
requestId: data.requestId,
|
||||
html: exportFullHtml(),
|
||||
});
|
||||
break;
|
||||
case 'insertToken':
|
||||
insertToken(data.token || '');
|
||||
break;
|
||||
case 'destroy':
|
||||
destroyEditor();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
initEditor();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user