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
|
if (isProd) base = "Nhance HR"; // You can differentiate names if needed
|
||||||
|
|
||||||
switch (routeName) {
|
switch (routeName) {
|
||||||
case 'hrDashboard':
|
|
||||||
return "$base - Insights";
|
|
||||||
case 'claimsOverviewDashboard':
|
case 'claimsOverviewDashboard':
|
||||||
return "$base - Dashboard";
|
return "$base - Dashboard";
|
||||||
case 'policies':
|
case 'policies':
|
||||||
|
|||||||
@ -52,7 +52,7 @@ class _CustomAppBarState extends State<CustomAppBar> {
|
|||||||
prefs.setBool('showBackToHR', false);
|
prefs.setBool('showBackToHR', false);
|
||||||
await prefs.remove('hrSelectArgumentsData');
|
await prefs.remove('hrSelectArgumentsData');
|
||||||
prefs.remove('token');
|
prefs.remove('token');
|
||||||
Navigator.pushNamed(context, 'hrDashboard');
|
Navigator.pushNamed(context, 'policies');
|
||||||
} else {
|
} else {
|
||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
Navigator.pushNamed(context, 'phone');
|
Navigator.pushNamed(context, 'phone');
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../service/token_storage_service.dart';
|
import '../service/token_storage_service.dart';
|
||||||
|
|
||||||
@ -29,6 +31,18 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
|
|||||||
setState(() {});
|
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 {
|
Future<void> _onBranchSelected(Map<String, dynamic> branch) async {
|
||||||
final tokenStorage = TokenStorageService();
|
final tokenStorage = TokenStorageService();
|
||||||
|
|
||||||
@ -36,9 +50,11 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
|
|||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
// 🔄 Refresh CURRENT PAGE only
|
_loadBranches();
|
||||||
final route = ModalRoute.of(context)?.settings.name ?? 'hrDashboard';
|
|
||||||
|
|
||||||
|
final route = await _getBranchSwitchRoute();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
Navigator.pushReplacementNamed(context, route);
|
Navigator.pushReplacementNamed(context, route);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -366,7 +366,7 @@ class _MyEmailVerifyState extends State<MyEmailVerify> {
|
|||||||
if (_postToken != null && _postToken.isNotEmpty) {
|
if (_postToken != null && _postToken.isNotEmpty) {
|
||||||
ToastHelper.showSuccessToast(context, 'Successfully Logged In');
|
ToastHelper.showSuccessToast(context, 'Successfully Logged In');
|
||||||
// if (emp_status == 'enrolled' || emp_status == 'active') {
|
// if (emp_status == 'enrolled' || emp_status == 'active') {
|
||||||
Navigator.pushReplacementNamed(context, 'hrDashboard');
|
Navigator.pushReplacementNamed(context, 'policies');
|
||||||
// } else {
|
// } else {
|
||||||
// Navigator.pushReplacementNamed(context, 'empDetails');
|
// Navigator.pushReplacementNamed(context, 'empDetails');
|
||||||
// }
|
// }
|
||||||
@ -410,7 +410,7 @@ class _MyEmailVerifyState extends State<MyEmailVerify> {
|
|||||||
// if (emp_status == 'enrolled' || emp_status == 'active') {
|
// if (emp_status == 'enrolled' || emp_status == 'active') {
|
||||||
// Navigator.pushReplacementNamed(context, 'home');
|
// Navigator.pushReplacementNamed(context, 'home');
|
||||||
// } else {
|
// } 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/session/session_service.dart';
|
||||||
import 'package:nhancepolicy/service/token_storage_service.dart';
|
import 'package:nhancepolicy/service/token_storage_service.dart';
|
||||||
import 'package:nhancepolicy/home.dart';
|
import 'package:nhancepolicy/home.dart';
|
||||||
import 'package:nhancepolicy/presentation/hrDashboard.dart';
|
|
||||||
import 'package:nhancepolicy/presentation/claims_overview/claims_overview_dashboard.dart';
|
import 'package:nhancepolicy/presentation/claims_overview/claims_overview_dashboard.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:nhancepolicy/hrLogin.dart';
|
import 'package:nhancepolicy/hrLogin.dart';
|
||||||
@ -267,7 +266,7 @@ final Map<String, WidgetBuilder> appRoutes = {
|
|||||||
'empDetails': (context) => empDetails(),
|
'empDetails': (context) => empDetails(),
|
||||||
'addOnsDetails': (context) => addOnsDetails(),
|
'addOnsDetails': (context) => addOnsDetails(),
|
||||||
'empReviewDetails': (context) => empReviewDetails(),
|
'empReviewDetails': (context) => empReviewDetails(),
|
||||||
'hrDashboard': (context) => hrDashboard(),
|
'hrDashboard': (context) => policies(),
|
||||||
'claimsOverviewDashboard': (context) => const ClaimsOverviewDashboard(),
|
'claimsOverviewDashboard': (context) => const ClaimsOverviewDashboard(),
|
||||||
'hrPolicyDetails': (context) => hrPolicyDetails(
|
'hrPolicyDetails': (context) => hrPolicyDetails(
|
||||||
ClientId: '',
|
ClientId: '',
|
||||||
|
|||||||
@ -58,7 +58,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
|
|||||||
(Icons.people_outline, 'Demographics'),
|
(Icons.people_outline, 'Demographics'),
|
||||||
(Icons.location_city_outlined, 'Hospitals & Geography'),
|
(Icons.location_city_outlined, 'Hospitals & Geography'),
|
||||||
(Icons.medical_services_outlined, 'Specialty Analysis'),
|
(Icons.medical_services_outlined, 'Specialty Analysis'),
|
||||||
(Icons.group_add_outlined, 'Insurred'),
|
(Icons.group_add_outlined, 'Insured'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -1026,7 +1026,7 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
|
|||||||
ClaimsOverviewTheme.pink,
|
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 trendHeight = widget.forPdfExport ? 228.0 : 320.0;
|
||||||
final sectionGap = widget.forPdfExport ? 8.0 : 16.0;
|
final sectionGap = widget.forPdfExport ? 8.0 : 16.0;
|
||||||
|
|
||||||
@ -1235,19 +1235,16 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
|
|||||||
animationKey: _chartKey,
|
animationKey: _chartKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (forPdfExport) {
|
|
||||||
return chart;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: chart),
|
Expanded(child: chart),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: forPdfExport ? 4 : 8),
|
||||||
_EnrollmentDonutLegend(
|
_EnrollmentDonutLegend(
|
||||||
labels: series.labels,
|
labels: series.labels,
|
||||||
values: series.values,
|
values: series.values,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
totalLabel: totalLabel,
|
totalLabel: totalLabel,
|
||||||
|
compact: forPdfExport,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -1259,20 +1256,23 @@ class _EnrollmentDonutLegend extends StatelessWidget {
|
|||||||
final List<double> values;
|
final List<double> values;
|
||||||
final List<Color> colors;
|
final List<Color> colors;
|
||||||
final String totalLabel;
|
final String totalLabel;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
const _EnrollmentDonutLegend({
|
const _EnrollmentDonutLegend({
|
||||||
required this.labels,
|
required this.labels,
|
||||||
required this.values,
|
required this.values,
|
||||||
required this.colors,
|
required this.colors,
|
||||||
required this.totalLabel,
|
required this.totalLabel,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final total = values.fold<double>(0, (a, b) => a + b);
|
final total = values.fold<double>(0, (a, b) => a + b);
|
||||||
|
final fontSize = compact ? 9.0 : 11.0;
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 12,
|
spacing: compact ? 8 : 12,
|
||||||
runSpacing: 8,
|
runSpacing: compact ? 4 : 8,
|
||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@ -1282,18 +1282,18 @@ class _EnrollmentDonutLegend extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 10,
|
width: compact ? 8 : 10,
|
||||||
height: 10,
|
height: compact ? 8 : 10,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors[i % colors.length],
|
color: colors[i % colors.length],
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
SizedBox(width: compact ? 4 : 6),
|
||||||
Text(
|
Text(
|
||||||
'${labels[i]} ${pct.toStringAsFixed(1)}%',
|
'${labels[i]} ${pct.toStringAsFixed(1)}%',
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(
|
||||||
fontSize: 11,
|
fontSize: fontSize,
|
||||||
color: ClaimsOverviewTheme.textSecondary,
|
color: ClaimsOverviewTheme.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1303,7 +1303,7 @@ class _EnrollmentDonutLegend extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Total: $totalLabel',
|
'Total: $totalLabel',
|
||||||
style: GoogleFonts.poppins(
|
style: GoogleFonts.poppins(
|
||||||
fontSize: 11,
|
fontSize: fontSize,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: ClaimsOverviewTheme.textPrimary,
|
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/base_layout.dart';
|
||||||
import '../customAppBar/customFooter.dart';
|
import '../customAppBar/customFooter.dart';
|
||||||
import '../service/secure_pop_scope.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';
|
import 'package:nhancepolicy/logger.dart';
|
||||||
|
|
||||||
class hrPolicyDetails extends StatefulWidget {
|
class hrPolicyDetails extends StatefulWidget {
|
||||||
@ -513,10 +514,34 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _isEnrolledStatus(String status) =>
|
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) =>
|
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) {
|
bool _matchesPreStatusFilter(Map<String, dynamic> row, String filter) {
|
||||||
final status = row['status']?.toString().toLowerCase().trim() ?? '';
|
final status = row['status']?.toString().toLowerCase().trim() ?? '';
|
||||||
@ -530,9 +555,9 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
|||||||
case 'not_enrolled':
|
case 'not_enrolled':
|
||||||
return !_isEnrolledStatus(status);
|
return !_isEnrolledStatus(status);
|
||||||
case 'logged_in':
|
case 'logged_in':
|
||||||
return _isLoggedInRow(row);
|
return _isSelfRow(row) && _isLoggedInRow(row);
|
||||||
case 'not_logged_in':
|
case 'not_logged_in':
|
||||||
return !_isLoggedInRow(row);
|
return _isSelfRow(row) && !_isLoggedInRow(row);
|
||||||
case 'draft':
|
case 'draft':
|
||||||
return status == 'draft';
|
return status == 'draft';
|
||||||
default:
|
default:
|
||||||
@ -958,7 +983,9 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openReminderConfigDialog() async {
|
Future<void> _openReminderConfigDialog() async {
|
||||||
|
final clientId = localClientId ?? widget.ClientId;
|
||||||
final clientPolicyId = localClientPolicyId ?? widget.ClientPoliyId;
|
final clientPolicyId = localClientPolicyId ?? widget.ClientPoliyId;
|
||||||
|
final clientBranchId = localClientBranchId ?? widget.clientBranchId;
|
||||||
final token = localToken ?? widget.Token;
|
final token = localToken ?? widget.Token;
|
||||||
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
@ -966,9 +993,13 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
|||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return _ReminderMailConfigDialog(
|
return _ReminderMailConfigDialog(
|
||||||
|
clientId: clientId,
|
||||||
clientPolicyId: clientPolicyId,
|
clientPolicyId: clientPolicyId,
|
||||||
|
clientBranchId: clientBranchId,
|
||||||
token: token,
|
token: token,
|
||||||
apiService: apiService,
|
apiService: apiService,
|
||||||
|
defaultSubject: _defaultReminderSubject(),
|
||||||
|
defaultHtmlBody: _defaultReminderHtmlBody(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -979,193 +1010,36 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
|
|||||||
return 'Reminder: Complete Your Enrollment - $policyName';
|
return 'Reminder: Complete Your Enrollment - $policyName';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _defaultReminderBody() {
|
String _defaultReminderHtmlBody() {
|
||||||
final policyName = localCardPolicyName ?? widget.cardPolicy_name;
|
return kDefaultEnrollmentReminderHtmlBody;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openReminderTemplateDialog() async {
|
Future<void> _openReminderTemplateDialog() async {
|
||||||
final template = await _loadReminderEmailTemplate();
|
final clientId = localClientId ?? widget.ClientId;
|
||||||
if (!mounted) return;
|
final clientPolicyId = localClientPolicyId ?? widget.ClientPoliyId;
|
||||||
|
final clientBranchId = localClientBranchId ?? widget.clientBranchId;
|
||||||
|
final token = localToken ?? widget.Token;
|
||||||
|
|
||||||
final subjectController = TextEditingController(text: template.subject);
|
await showDialog<void>(
|
||||||
final bodyController = TextEditingController(text: template.body);
|
|
||||||
|
|
||||||
final result = await showDialog<Map<String, String>?>(
|
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return ReminderEmailTemplateDialog(
|
||||||
shape: RoundedRectangleBorder(
|
clientId: clientId,
|
||||||
borderRadius: BorderRadius.circular(16),
|
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({
|
Future<void> _sendEnrollmentReminder({
|
||||||
@ -1176,7 +1050,7 @@ HR Team''';
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final hrId = await tokenService.readValue('enrollmentHrId');
|
final hrId = await tokenService.readValue('enrollmentHrId');
|
||||||
final response = await apiService.sendEnrollmentReminderApi(
|
final response = await apiService.sendReminderMailApi(
|
||||||
localClientId ?? widget.ClientId,
|
localClientId ?? widget.ClientId,
|
||||||
localClientPolicyId ?? widget.ClientPoliyId,
|
localClientPolicyId ?? widget.ClientPoliyId,
|
||||||
localClientBranchId ?? widget.clientBranchId,
|
localClientBranchId ?? widget.clientBranchId,
|
||||||
@ -1383,28 +1257,6 @@ HR Team''';
|
|||||||
/// Push right content to end
|
/// Push right content to end
|
||||||
const Spacer(),
|
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) ...[
|
if (localIsEcardBulkDownload == 1) ...[
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@ -1687,7 +1539,7 @@ HR Team''';
|
|||||||
// ---------------- PREMIUM / STATUS ----------------
|
// ---------------- PREMIUM / STATUS ----------------
|
||||||
if (localTokenType == "pre")
|
if (localTokenType == "pre")
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 15),
|
padding: const EdgeInsets.only(left: 15, right: 16),
|
||||||
child: _buildStatusSummary(),
|
child: _buildStatusSummary(),
|
||||||
),
|
),
|
||||||
|
|
||||||
@ -1695,6 +1547,7 @@ HR Team''';
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@ -1712,6 +1565,8 @@ HR Team''';
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_buildCompactSearchField(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -2069,10 +1924,12 @@ HR Team''';
|
|||||||
} else {
|
} else {
|
||||||
notEnrolled++;
|
notEnrolled++;
|
||||||
}
|
}
|
||||||
if (_isLoggedInRow(item)) {
|
if (_isSelfRow(item)) {
|
||||||
loggedIn++;
|
if (_isLoggedInRow(item)) {
|
||||||
} else {
|
loggedIn++;
|
||||||
notLoggedIn++;
|
} else {
|
||||||
|
notLoggedIn++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (status == 'draft') draft++;
|
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({
|
Widget _buildPreStatusFilterChip({
|
||||||
required String filterKey,
|
required String filterKey,
|
||||||
required String label,
|
required String label,
|
||||||
@ -2158,51 +2046,63 @@ HR Team''';
|
|||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 38,
|
height: 38,
|
||||||
child: ListView.separated(
|
child: Row(
|
||||||
scrollDirection: Axis.horizontal,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
itemCount: filters.length + 1,
|
children: [
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
Expanded(
|
||||||
itemBuilder: (context, index) {
|
child: ListView.separated(
|
||||||
if (index == filters.length) {
|
scrollDirection: Axis.horizontal,
|
||||||
return Material(
|
itemCount: filters.length + 1,
|
||||||
color: Colors.transparent,
|
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
||||||
child: InkWell(
|
itemBuilder: (context, index) {
|
||||||
onTap: _preStatusFilter == null ? null : _resetPreStatusFilter,
|
if (index == filters.length) {
|
||||||
borderRadius: BorderRadius.circular(6),
|
return Material(
|
||||||
child: Container(
|
color: Colors.transparent,
|
||||||
padding:
|
child: InkWell(
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
onTap:
|
||||||
decoration: BoxDecoration(
|
_preStatusFilter == null ? null : _resetPreStatusFilter,
|
||||||
color: const Color(0xFFF0F0F0),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderRadius: BorderRadius.circular(6),
|
child: Container(
|
||||||
border: Border.all(
|
padding: const EdgeInsets.symmetric(
|
||||||
color: _preStatusFilter != null
|
horizontal: 12,
|
||||||
? const Color(0xFF009195)
|
vertical: 8,
|
||||||
: const Color(0xFFD0D0D0),
|
),
|
||||||
|
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];
|
final filter = filters[index];
|
||||||
return _buildPreStatusFilterChip(
|
return _buildPreStatusFilterChip(
|
||||||
filterKey: filter['key']!,
|
filterKey: filter['key']!,
|
||||||
label: filter['label']!,
|
label: filter['label']!,
|
||||||
count: statusCounts[filter['key']] ?? 0,
|
count: statusCounts[filter['key']] ?? 0,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildCompactSearchField(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2221,6 +2121,7 @@ HR Team''';
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
delegate: _CDHeaderDelegate(
|
delegate: _CDHeaderDelegate(
|
||||||
showUHID: localPolicyTypeId != '6' && localPolicyTypeId != '7',
|
showUHID: localPolicyTypeId != '6' && localPolicyTypeId != '7',
|
||||||
|
showLoggedIn: localTokenType == "pre",
|
||||||
showAction:
|
showAction:
|
||||||
localTokenType != "pre" && (hasAnyEcardLink || hasModule),
|
localTokenType != "pre" && (hasAnyEcardLink || hasModule),
|
||||||
),
|
),
|
||||||
@ -2302,6 +2203,30 @@ HR Team''';
|
|||||||
child: Text(item['email_corporate'] ?? '-', style: _dataBold),
|
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
|
/// STATUS
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
@ -3236,10 +3161,12 @@ HR Team''';
|
|||||||
|
|
||||||
class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
|
class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||||
final bool showUHID;
|
final bool showUHID;
|
||||||
|
final bool showLoggedIn;
|
||||||
final bool showAction;
|
final bool showAction;
|
||||||
|
|
||||||
_CDHeaderDelegate({
|
_CDHeaderDelegate({
|
||||||
required this.showUHID,
|
required this.showUHID,
|
||||||
|
required this.showLoggedIn,
|
||||||
required this.showAction,
|
required this.showAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -3265,6 +3192,8 @@ class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|||||||
_headerCell('Gender', 2),
|
_headerCell('Gender', 2),
|
||||||
_headerCell('Mobile', 2),
|
_headerCell('Mobile', 2),
|
||||||
_headerCell('Email', 5),
|
_headerCell('Email', 5),
|
||||||
|
if (showLoggedIn) _headerCell('Logged In', 2),
|
||||||
|
if (showLoggedIn) const SizedBox(width: 8),
|
||||||
_headerCell('Status', 2),
|
_headerCell('Status', 2),
|
||||||
if (showAction) _headerCell('Action', 3, center: true),
|
if (showAction) _headerCell('Action', 3, center: true),
|
||||||
],
|
],
|
||||||
@ -3368,14 +3297,22 @@ class _PolicyTermsDialog extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ReminderMailConfigDialog extends StatefulWidget {
|
class _ReminderMailConfigDialog extends StatefulWidget {
|
||||||
|
final String clientId;
|
||||||
final String clientPolicyId;
|
final String clientPolicyId;
|
||||||
|
final String clientBranchId;
|
||||||
final String token;
|
final String token;
|
||||||
final ApiService apiService;
|
final ApiService apiService;
|
||||||
|
final String defaultSubject;
|
||||||
|
final String defaultHtmlBody;
|
||||||
|
|
||||||
const _ReminderMailConfigDialog({
|
const _ReminderMailConfigDialog({
|
||||||
|
required this.clientId,
|
||||||
required this.clientPolicyId,
|
required this.clientPolicyId,
|
||||||
|
required this.clientBranchId,
|
||||||
required this.token,
|
required this.token,
|
||||||
required this.apiService,
|
required this.apiService,
|
||||||
|
required this.defaultSubject,
|
||||||
|
required this.defaultHtmlBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isBusy = _isSaving;
|
final isBusy = _isSaving;
|
||||||
@ -3830,6 +3786,26 @@ class _ReminderMailConfigDialogState extends State<_ReminderMailConfigDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildDaysField(),
|
_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);
|
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(
|
Future<Map<String, dynamic>> sendEnrollmentReminderApi(
|
||||||
String clientId,
|
String clientId,
|
||||||
String clientPolicyId,
|
String clientPolicyId,
|
||||||
@ -1157,12 +1201,17 @@ class ApiService {
|
|||||||
|
|
||||||
Future<Map<String, dynamic>> getReminderMailConfigApi(
|
Future<Map<String, dynamic>> getReminderMailConfigApi(
|
||||||
String clientPolicyId,
|
String clientPolicyId,
|
||||||
String token,
|
String token, {
|
||||||
) async {
|
bool isEmailTemplate = false,
|
||||||
final url = Uri.parse(
|
}) async {
|
||||||
|
final query = StringBuffer(
|
||||||
'${Environment.apiUrl}getReminderMailConfig'
|
'${Environment.apiUrl}getReminderMailConfig'
|
||||||
'?client_policy_id=$clientPolicyId',
|
'?client_policy_id=$clientPolicyId',
|
||||||
);
|
);
|
||||||
|
if (isEmailTemplate) {
|
||||||
|
query.write('&is_email_template=true');
|
||||||
|
}
|
||||||
|
final url = Uri.parse(query.toString());
|
||||||
final headers = {
|
final headers = {
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
};
|
};
|
||||||
@ -1182,20 +1231,31 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> sendReminderMailApi(
|
Future<Map<String, dynamic>> sendReminderMailApi(
|
||||||
|
String clientId,
|
||||||
String clientPolicyId,
|
String clientPolicyId,
|
||||||
String token,
|
String clientBranchId,
|
||||||
) async {
|
String hrId,
|
||||||
|
String token, {
|
||||||
|
required String emailSubject,
|
||||||
|
required String emailBody,
|
||||||
|
}) async {
|
||||||
final url = Uri.parse('${Environment.apiUrl}sendReminderMail');
|
final url = Uri.parse('${Environment.apiUrl}sendReminderMail');
|
||||||
final headers = {
|
final headers = {
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
|
'APP-SIGNATURE':
|
||||||
|
'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
final policyId = int.tryParse(clientPolicyId) ?? clientPolicyId;
|
final policyId = int.tryParse(clientPolicyId) ?? clientPolicyId;
|
||||||
return _makePostRequest(
|
final body = {
|
||||||
url,
|
'client_id': clientId,
|
||||||
jsonEncode({'client_policy_id': policyId}),
|
'client_policy_id': policyId,
|
||||||
headers,
|
'client_branch_id': clientBranchId,
|
||||||
);
|
'hr_id': hrId,
|
||||||
|
'email_subject': emailSubject,
|
||||||
|
'email_body': emailBody,
|
||||||
|
};
|
||||||
|
return _makePostRequest(url, jsonEncode(body), headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getClientRMApi(
|
Future<Map<String, dynamic>> getClientRMApi(
|
||||||
|
|||||||
@ -64,6 +64,7 @@ dependencies:
|
|||||||
dropdown_button2: ^2.3.9
|
dropdown_button2: ^2.3.9
|
||||||
fl_chart: ^1.2.0
|
fl_chart: ^1.2.0
|
||||||
image: ^4.3.0
|
image: ^4.3.0
|
||||||
|
pointer_interceptor: ^0.10.1+2
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
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