460 lines
14 KiB
Dart
460 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:nhance_app_pwa/features/chatbot/data/chatbot_api_service.dart';
|
||
import 'package:nhance_app_pwa/features/chatbot/data/chatbot_repository.dart';
|
||
import 'package:nhance_app_pwa/features/chatbot/domain/models/chatbot_models.dart';
|
||
import 'package:nhance_app_pwa/pages/helpers/ecard_download_notification_service.dart';
|
||
import 'package:nhance_app_pwa/pages/helpers/ecard_download_service.dart';
|
||
import 'package:nhance_app_pwa/pages/service/TokenService.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
|
||
final chatbotRepositoryProvider = Provider<ChatbotRepository>((ref) {
|
||
return ChatbotRepository(ChatbotApiService());
|
||
});
|
||
|
||
final chatbotControllerProvider =
|
||
StateNotifierProvider<ChatbotController, ChatbotUiState>((ref) {
|
||
return ChatbotController(ref.read(chatbotRepositoryProvider));
|
||
});
|
||
|
||
class ChatbotUiState {
|
||
final bool isOpen;
|
||
final bool isIntroAnimating;
|
||
final bool isLoading;
|
||
final String? errorMessage;
|
||
final ChatbotFlowStep step;
|
||
final List<ChatbotMessage> messages;
|
||
final List<ChatbotOption> options;
|
||
final List<ChatbotPolicy> policies;
|
||
final ChatbotPolicy? selectedPolicy;
|
||
final List<ClaimStatusItem> claimStatuses;
|
||
|
||
const ChatbotUiState({
|
||
required this.isOpen,
|
||
required this.isIntroAnimating,
|
||
required this.isLoading,
|
||
required this.errorMessage,
|
||
required this.step,
|
||
required this.messages,
|
||
required this.options,
|
||
required this.policies,
|
||
required this.selectedPolicy,
|
||
required this.claimStatuses,
|
||
});
|
||
|
||
factory ChatbotUiState.initial() {
|
||
return ChatbotUiState(
|
||
isOpen: false,
|
||
isIntroAnimating: false,
|
||
isLoading: false,
|
||
errorMessage: null,
|
||
step: ChatbotFlowStep.rootMenu,
|
||
messages: [
|
||
ChatbotMessage(
|
||
text: 'Welcome! I’m here to assist you with your policy services. Please choose an option to continue.',
|
||
isBot: true,
|
||
createdAt: DateTime.now(),
|
||
),
|
||
],
|
||
options: const [
|
||
ChatbotOption(id: 'my_policies', label: 'My Policies'),
|
||
ChatbotOption(id: 'claims_procedure', label: 'Claims Procedure'),
|
||
ChatbotOption(id: 'my_claims_status', label: 'My Claims Status'),
|
||
ChatbotOption(id: 'help', label: 'Help'),
|
||
],
|
||
policies: const [],
|
||
selectedPolicy: null,
|
||
claimStatuses: const [],
|
||
);
|
||
}
|
||
|
||
ChatbotUiState copyWith({
|
||
bool? isOpen,
|
||
bool? isIntroAnimating,
|
||
bool? isLoading,
|
||
String? errorMessage,
|
||
bool clearError = false,
|
||
ChatbotFlowStep? step,
|
||
List<ChatbotMessage>? messages,
|
||
List<ChatbotOption>? options,
|
||
List<ChatbotPolicy>? policies,
|
||
ChatbotPolicy? selectedPolicy,
|
||
bool clearSelectedPolicy = false,
|
||
List<ClaimStatusItem>? claimStatuses,
|
||
}) {
|
||
return ChatbotUiState(
|
||
isOpen: isOpen ?? this.isOpen,
|
||
isIntroAnimating: isIntroAnimating ?? this.isIntroAnimating,
|
||
isLoading: isLoading ?? this.isLoading,
|
||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||
step: step ?? this.step,
|
||
messages: messages ?? this.messages,
|
||
options: options ?? this.options,
|
||
policies: policies ?? this.policies,
|
||
selectedPolicy:
|
||
clearSelectedPolicy ? null : (selectedPolicy ?? this.selectedPolicy),
|
||
claimStatuses: claimStatuses ?? this.claimStatuses,
|
||
);
|
||
}
|
||
}
|
||
|
||
class ChatbotController extends StateNotifier<ChatbotUiState> {
|
||
final ChatbotRepository _repository;
|
||
final EcardDownloadService _ecardDownloadService = const EcardDownloadService();
|
||
List<ChatbotPolicy>? _policiesCache;
|
||
DateTime? _policiesCacheAt;
|
||
static const Duration _cacheTtl = Duration(minutes: 10);
|
||
|
||
bool _didHomeIntroAnimation = false;
|
||
|
||
ChatbotController(this._repository) : super(ChatbotUiState.initial());
|
||
|
||
void toggleOpen() {
|
||
state = state.copyWith(isOpen: !state.isOpen);
|
||
}
|
||
|
||
void close() {
|
||
state = state.copyWith(isOpen: false);
|
||
}
|
||
|
||
void resetAll() {
|
||
_policiesCache = null;
|
||
_policiesCacheAt = null;
|
||
_didHomeIntroAnimation = false;
|
||
state = ChatbotUiState.initial();
|
||
}
|
||
|
||
Future<void> triggerHomeIntroAnimation() async {
|
||
if (_didHomeIntroAnimation) return;
|
||
_didHomeIntroAnimation = true;
|
||
|
||
state = state.copyWith(isIntroAnimating: true);
|
||
|
||
await Future.delayed(const Duration(milliseconds: 900));
|
||
state = state.copyWith(isIntroAnimating: false);
|
||
}
|
||
|
||
List<ClaimStatusItem> _latestThreeClaims(List<ClaimStatusItem> claims) {
|
||
if (claims.length <= 3) return List<ClaimStatusItem>.from(claims.reversed);
|
||
return claims.reversed.take(3).toList();
|
||
}
|
||
|
||
void _appendUserMessage(String text) {
|
||
final updated = List<ChatbotMessage>.from(state.messages)
|
||
..add(
|
||
ChatbotMessage(
|
||
text: text,
|
||
isBot: false,
|
||
createdAt: DateTime.now(),
|
||
),
|
||
);
|
||
state = state.copyWith(messages: updated);
|
||
}
|
||
|
||
void _appendBotMessage(String text) {
|
||
final updated = List<ChatbotMessage>.from(state.messages)
|
||
..add(
|
||
ChatbotMessage(
|
||
text: text,
|
||
isBot: true,
|
||
createdAt: DateTime.now(),
|
||
),
|
||
);
|
||
state = state.copyWith(messages: updated);
|
||
}
|
||
|
||
void _showRootMenu() {
|
||
state = state.copyWith(
|
||
step: ChatbotFlowStep.rootMenu,
|
||
options: const [
|
||
ChatbotOption(id: 'my_policies', label: 'My Policies'),
|
||
ChatbotOption(id: 'claims_procedure', label: 'Claims Procedure'),
|
||
ChatbotOption(id: 'my_claims_status', label: 'My Claims Status'),
|
||
ChatbotOption(id: 'help', label: 'Help'),
|
||
],
|
||
clearSelectedPolicy: true,
|
||
clearError: true,
|
||
);
|
||
}
|
||
|
||
Future<void> onOptionSelected(
|
||
BuildContext context,
|
||
ChatbotOption option,
|
||
) async {
|
||
_appendUserMessage(option.label);
|
||
|
||
switch (option.id) {
|
||
case 'my_policies':
|
||
await loadPolicies(context);
|
||
return;
|
||
case 'claims_procedure':
|
||
_appendBotMessage('Opening claims page.');
|
||
close();
|
||
context.go('/claimprocess', extra: 2);
|
||
return;
|
||
case 'my_claims_status':
|
||
await loadClaimsStatus(context);
|
||
return;
|
||
case 'help':
|
||
_appendBotMessage('Opening help page.');
|
||
close();
|
||
context.go('/help');
|
||
return;
|
||
case 'covered_member_list':
|
||
showCoveredMembers();
|
||
return;
|
||
case 'ecard_download':
|
||
await openEcard(context);
|
||
return;
|
||
case 'covered_hospital_list':
|
||
await openHospitalList();
|
||
return;
|
||
case 'back_to_root':
|
||
_appendBotMessage('Back to main menu.');
|
||
_showRootMenu();
|
||
return;
|
||
case 'back_to_policies':
|
||
_appendBotMessage('Choose a policy.');
|
||
state = state.copyWith(
|
||
step: ChatbotFlowStep.policyList,
|
||
options: _policyOptions(state.policies),
|
||
clearSelectedPolicy: true,
|
||
clearError: true,
|
||
);
|
||
return;
|
||
default:
|
||
if (option.id.startsWith('policy::')) {
|
||
final policyId = option.id.replaceFirst('policy::', '');
|
||
final selected = state.policies.firstWhere(
|
||
(policy) => policy.clientPolicyId == policyId,
|
||
orElse: () => state.policies.first,
|
||
);
|
||
selectPolicy(selected);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
List<ChatbotOption> _policyOptions(List<ChatbotPolicy> policies) {
|
||
final policyOptions = policies
|
||
.map((policy) => ChatbotOption(
|
||
id: 'policy::${policy.clientPolicyId}',
|
||
label: policy.policyName,
|
||
))
|
||
.toList();
|
||
return [
|
||
...policyOptions,
|
||
const ChatbotOption(id: 'back_to_root', label: 'Back'),
|
||
];
|
||
}
|
||
|
||
Future<void> loadPolicies(BuildContext context) async {
|
||
final now = DateTime.now();
|
||
final canUseCache = _policiesCache != null &&
|
||
_policiesCacheAt != null &&
|
||
now.difference(_policiesCacheAt!) < _cacheTtl;
|
||
|
||
if (canUseCache) {
|
||
final cachedPolicies = _policiesCache!;
|
||
if (cachedPolicies.isEmpty) {
|
||
_appendBotMessage('No active policies found.');
|
||
_showRootMenu();
|
||
} else {
|
||
_appendBotMessage('Choose a policy.');
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
policies: cachedPolicies,
|
||
step: ChatbotFlowStep.policyList,
|
||
options: _policyOptions(cachedPolicies),
|
||
clearError: true,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
state = state.copyWith(isLoading: true, clearError: true);
|
||
try {
|
||
final policies = await _repository.getPolicies(context);
|
||
_policiesCache = policies;
|
||
_policiesCacheAt = DateTime.now();
|
||
if (policies.isEmpty) {
|
||
_appendBotMessage('No active policies found.');
|
||
_showRootMenu();
|
||
} else {
|
||
_appendBotMessage('Choose a policy.');
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
policies: policies,
|
||
step: ChatbotFlowStep.policyList,
|
||
options: _policyOptions(policies),
|
||
);
|
||
}
|
||
} catch (_) {
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
errorMessage: 'Unable to load policies. Please try again.',
|
||
);
|
||
_appendBotMessage('Unable to load policies right now.');
|
||
_showRootMenu();
|
||
}
|
||
}
|
||
|
||
void selectPolicy(ChatbotPolicy policy) {
|
||
_appendBotMessage('Selected ${policy.policyName}.');
|
||
final isGmcPolicy = _isGmcPolicy(policy);
|
||
final actionOptions = <ChatbotOption>[
|
||
const ChatbotOption(id: 'covered_member_list', label: 'Covered Member List'),
|
||
if (isGmcPolicy)
|
||
const ChatbotOption(id: 'ecard_download', label: 'E-Card Download'),
|
||
if (isGmcPolicy)
|
||
const ChatbotOption(
|
||
id: 'covered_hospital_list',
|
||
label: 'Covered Hospital List',
|
||
),
|
||
const ChatbotOption(id: 'back_to_policies', label: 'Back to Policies'),
|
||
];
|
||
state = state.copyWith(
|
||
selectedPolicy: policy,
|
||
step: ChatbotFlowStep.policyActions,
|
||
options: actionOptions,
|
||
clearError: true,
|
||
);
|
||
}
|
||
|
||
bool _isGmcPolicy(ChatbotPolicy policy) {
|
||
final normalizedType = policy.policyType.trim().toUpperCase();
|
||
return normalizedType.startsWith('GMC');
|
||
}
|
||
|
||
void showCoveredMembers() {
|
||
final policy = state.selectedPolicy;
|
||
if (policy == null) {
|
||
_appendBotMessage('Please choose a policy first.');
|
||
return;
|
||
}
|
||
|
||
if (policy.members.isEmpty) {
|
||
_appendBotMessage('No covered members found for this policy.');
|
||
} else {
|
||
final names = policy.members
|
||
.map((member) => '${member.name} (${member.relationship})')
|
||
.join('\n');
|
||
_appendBotMessage('Covered members:\n$names');
|
||
}
|
||
}
|
||
|
||
Future<void> openEcard(BuildContext context) async {
|
||
final policy = state.selectedPolicy;
|
||
if (policy == null) {
|
||
_appendBotMessage('Please choose a policy first.');
|
||
return;
|
||
}
|
||
|
||
final url = policy.eCardDownloadUrl.trim();
|
||
if (url.isEmpty) {
|
||
_appendBotMessage('E-Card is not available for this policy.');
|
||
return;
|
||
}
|
||
|
||
final uri = Uri.tryParse(url);
|
||
if (uri == null) {
|
||
_appendBotMessage('E-Card link is invalid.');
|
||
return;
|
||
}
|
||
|
||
final fileName = _chatbotEcardFileName(policy);
|
||
final postToken = await TokenService.getPostToken();
|
||
final headers = <String, String>{
|
||
'APP-SIGNATURE': 'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
|
||
if (postToken != null && postToken.isNotEmpty)
|
||
'Authorization': 'Bearer $postToken',
|
||
};
|
||
|
||
if (!kIsWeb) {
|
||
_appendBotMessage('Downloading eCard...');
|
||
}
|
||
|
||
final result = await _ecardDownloadService.downloadEcard(
|
||
url: uri.toString(),
|
||
fileName: fileName,
|
||
headers: headers,
|
||
);
|
||
|
||
if (!result.success) {
|
||
_appendBotMessage(result.message ?? 'Unable to download E-Card.');
|
||
return;
|
||
}
|
||
|
||
if (!kIsWeb && result.savedPath != null && result.savedPath!.isNotEmpty) {
|
||
await EcardDownloadNotificationService.showDownloadCompleted(
|
||
filePath: result.savedPath!,
|
||
fileName: fileName,
|
||
);
|
||
}
|
||
|
||
_appendBotMessage(result.message ?? 'E-Card downloaded successfully.');
|
||
}
|
||
|
||
String _chatbotEcardFileName(ChatbotPolicy policy) {
|
||
final sanitized = policy.clientPolicyId.replaceAll(RegExp(r'[^\w\-.]'), '_');
|
||
if (sanitized.isEmpty) return 'ecard.pdf';
|
||
return 'ecard_$sanitized.pdf';
|
||
}
|
||
|
||
Future<void> openHospitalList() async {
|
||
final policy = state.selectedPolicy;
|
||
if (policy == null) {
|
||
_appendBotMessage('Please choose a policy first.');
|
||
return;
|
||
}
|
||
|
||
final url = policy.networkHospitalsUrl.trim();
|
||
if (url.isEmpty) {
|
||
_appendBotMessage('Hospital list link is not available.');
|
||
return;
|
||
}
|
||
|
||
final uri = Uri.tryParse(url);
|
||
if (uri == null) {
|
||
_appendBotMessage('Hospital list link is invalid.');
|
||
return;
|
||
}
|
||
|
||
final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||
if (!launched) {
|
||
_appendBotMessage('Unable to open hospital list link.');
|
||
return;
|
||
}
|
||
_appendBotMessage('Opened hospital list in browser.');
|
||
}
|
||
|
||
Future<void> loadClaimsStatus(BuildContext context) async {
|
||
state = state.copyWith(isLoading: true, clearError: true);
|
||
try {
|
||
final claims = await _repository.getClaimStatuses(context);
|
||
final latestClaims = _latestThreeClaims(claims);
|
||
if (claims.isEmpty) {
|
||
_appendBotMessage('No claims found.');
|
||
} else {
|
||
_appendBotMessage('Here is your latest claims status.');
|
||
}
|
||
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
step: ChatbotFlowStep.claimStatusList,
|
||
claimStatuses: latestClaims,
|
||
options: const [ChatbotOption(id: 'back_to_root', label: 'Back')],
|
||
);
|
||
} catch (_) {
|
||
state = state.copyWith(
|
||
isLoading: false,
|
||
errorMessage: 'Unable to load claims status. Please try again.',
|
||
);
|
||
_appendBotMessage('Unable to load claims status right now.');
|
||
_showRootMenu();
|
||
}
|
||
}
|
||
}
|