diff --git a/.gitignore b/.gitignore index d78bf9c..d207398 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ apps/eva_counselor_mobile/build/ .agent/developer.local.json .agent/*.local.json .agent/worktrees/ +.agent/task-state.json # Test audio outputs & pitch exploration tests/output_*.mp3 diff --git a/apps/eva_counselor_mobile/lib/core/routes/app_router.dart b/apps/eva_counselor_mobile/lib/core/routes/app_router.dart index c463540..a3e4963 100644 --- a/apps/eva_counselor_mobile/lib/core/routes/app_router.dart +++ b/apps/eva_counselor_mobile/lib/core/routes/app_router.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../features/auth/presentation/screens/partner_counselor_activation_screen.dart'; import '../../features/auth/presentation/screens/psychologist_login_screen.dart'; import '../../features/auth/presentation/screens/psychologist_registration_screen.dart'; import '../../features/dashboard/presentation/screens/counselor_main_shell_screen.dart'; @@ -11,7 +12,8 @@ import 'app_route_transitions.dart'; class AppRouter { static Route? onGenerateRoute(RouteSettings settings) { - switch (settings.name) { + final uri = Uri.parse(settings.name ?? '/'); + switch (uri.path) { case '/': case '/counselor-login': return FadeThroughPageRoute( @@ -19,6 +21,24 @@ class AppRouter { settings: settings, ); + case '/counselor-activation': + final args = settings.arguments as Map?; + final token = args?['token'] as String? ?? uri.queryParameters['token']; + final email = args?['email'] as String? ?? uri.queryParameters['email']; + final partnerName = + args?['partnerName'] as String? ?? uri.queryParameters['partner']; + final fullName = + args?['fullName'] as String? ?? uri.queryParameters['name']; + return FadeThroughPageRoute( + page: PartnerCounselorActivationScreen( + invitationToken: token, + prefilledEmail: email, + partnerName: partnerName, + fullName: fullName, + ), + settings: settings, + ); + case '/counselor-register': return FadeThroughPageRoute( page: const PsychologistRegistrationScreen(), @@ -51,6 +71,22 @@ class AppRouter { ); case '/counselor-video-call': + if (settings.arguments is Map) { + final args = settings.arguments as Map; + final sessionId = args['sessionId'] as String? ?? 'session-live-001'; + final patientName = args['patientName'] as String?; + final scheduledTime = args['scheduledTime'] as String?; + final isLiveNow = args['isLiveNow'] as bool? ?? true; + return FadeThroughPageRoute( + page: VideoConsultationScreen( + sessionId: sessionId, + patientName: patientName, + scheduledTime: scheduledTime, + isEarlyEntry: !isLiveNow, + ), + settings: settings, + ); + } final sessionId = settings.arguments as String? ?? 'session-live-001'; return FadeThroughPageRoute( page: VideoConsultationScreen(sessionId: sessionId), diff --git a/apps/eva_counselor_mobile/lib/core/widgets/custom_button.dart b/apps/eva_counselor_mobile/lib/core/widgets/custom_button.dart index 626ee9c..2aa86b5 100644 --- a/apps/eva_counselor_mobile/lib/core/widgets/custom_button.dart +++ b/apps/eva_counselor_mobile/lib/core/widgets/custom_button.dart @@ -57,13 +57,13 @@ class CustomButton extends StatelessWidget { height: height, width: width ?? double.infinity, decoration: BoxDecoration( - color: disabled ? bg.withOpacity(0.5) : bg, + color: disabled ? bg.withValues(alpha: 0.5) : bg, borderRadius: BorderRadius.circular(16.0), border: border, boxShadow: variant == ButtonVariant.primary && !disabled ? [ BoxShadow( - color: AppColors.primary.withOpacity(0.3), + color: AppColors.primary.withValues(alpha: 0.25), blurRadius: 16, offset: const Offset(0, 6), ), diff --git a/apps/eva_counselor_mobile/lib/core/widgets/custom_text_field.dart b/apps/eva_counselor_mobile/lib/core/widgets/custom_text_field.dart index 2f6f9c1..4bad3a5 100644 --- a/apps/eva_counselor_mobile/lib/core/widgets/custom_text_field.dart +++ b/apps/eva_counselor_mobile/lib/core/widgets/custom_text_field.dart @@ -5,6 +5,7 @@ import '../theme/app_typography.dart'; class CustomTextField extends StatelessWidget { final String label; final String? hint; + final String? helperText; final TextEditingController? controller; final bool obscureText; final TextInputType? keyboardType; @@ -14,11 +15,13 @@ class CustomTextField extends StatelessWidget { final int maxLines; final bool readOnly; final VoidCallback? onTap; + final Color? fillColor; const CustomTextField({ super.key, required this.label, this.hint, + this.helperText, this.controller, this.obscureText = false, this.keyboardType, @@ -28,6 +31,7 @@ class CustomTextField extends StatelessWidget { this.maxLines = 1, this.readOnly = false, this.onTap, + this.fillColor, }); @override @@ -54,26 +58,37 @@ class CustomTextField extends StatelessWidget { style: AppTypography.bodyLarge.copyWith(fontSize: 14), decoration: InputDecoration( hintText: hint, + helperText: helperText, + helperMaxLines: 2, + helperStyle: const TextStyle(fontSize: 11, color: AppColors.textSecondary), hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.textLight), filled: true, - fillColor: Colors.white, + fillColor: fillColor ?? const Color(0xFFF7F8FC), prefixIcon: prefixIcon != null ? Icon(prefixIcon, color: AppColors.primary, size: 20) : null, suffixIcon: suffixIcon, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: AppColors.divider), + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE5E9F2), width: 1.0), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: AppColors.divider), + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: Color(0xFFE5E9F2), width: 1.0), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: AppColors.primary, width: 1.5), ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: AppColors.error.withValues(alpha: 0.6), width: 1.0), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: AppColors.error, width: 1.5), + ), ), ), ], diff --git a/apps/eva_counselor_mobile/lib/core/widgets/status_badge.dart b/apps/eva_counselor_mobile/lib/core/widgets/status_badge.dart index 476785e..37e4915 100644 --- a/apps/eva_counselor_mobile/lib/core/widgets/status_badge.dart +++ b/apps/eva_counselor_mobile/lib/core/widgets/status_badge.dart @@ -55,12 +55,48 @@ class StatusBadge extends StatelessWidget { factory StatusBadge.consentLocked([String label = 'Akses Terkunci']) { return StatusBadge( label: label, - backgroundColor: Colors.black.withOpacity(0.06), + backgroundColor: Colors.black.withValues(alpha: 0.06), textColor: AppColors.textSecondary, icon: Icons.lock_outline_rounded, ); } + factory StatusBadge.volunteer([String label = 'Relawan Teman Sebaya']) { + return StatusBadge( + label: label, + backgroundColor: const Color(0xFFE0F7F6), + textColor: const Color(0xFF00796B), + icon: Icons.volunteer_activism_rounded, + ); + } + + factory StatusBadge.proIndependent([String label = 'Psikolog Mandiri']) { + return StatusBadge( + label: label, + backgroundColor: AppColors.primaryContainer, + textColor: AppColors.primary, + icon: Icons.verified_user_rounded, + ); + } + + factory StatusBadge.proPartner([String label = 'Psikolog Mitra']) { + return StatusBadge( + label: label, + backgroundColor: const Color(0xFFF3E8FF), + textColor: const Color(0xFF7E22CE), + icon: Icons.apartment_rounded, + ); + } + + factory StatusBadge.coins(int coins) { + return StatusBadge( + label: '$coins Koin', + backgroundColor: const Color(0xFFFFF7ED), + textColor: const Color(0xFFC2410C), + icon: Icons.monetization_on_rounded, + ); + } + @override Widget build(BuildContext context) { return Container( diff --git a/apps/eva_counselor_mobile/lib/core/widgets/web_phone_frame_wrapper.dart b/apps/eva_counselor_mobile/lib/core/widgets/web_phone_frame_wrapper.dart index 12e0a03..cec4fe5 100644 --- a/apps/eva_counselor_mobile/lib/core/widgets/web_phone_frame_wrapper.dart +++ b/apps/eva_counselor_mobile/lib/core/widgets/web_phone_frame_wrapper.dart @@ -27,7 +27,7 @@ class WebPhoneFrameWrapper extends StatelessWidget { borderRadius: BorderRadius.circular(48), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.5), + color: Colors.black.withValues(alpha: 0.5), blurRadius: 30, offset: const Offset(0, 15), ), diff --git a/apps/eva_counselor_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart b/apps/eva_counselor_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart index 9098be9..2451607 100644 --- a/apps/eva_counselor_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart +++ b/apps/eva_counselor_mobile/lib/features/auth/data/datasources/mock_auth_datasource.dart @@ -3,30 +3,50 @@ import 'auth_datasource.dart'; class MockAuthDataSource implements AuthDataSource { bool _mockOnlineState = true; + final Map _registeredAccounts = {}; @override Future login( PsychologistLoginRequestDto request) async { await Future.delayed(const Duration(milliseconds: 600)); + final lowerEmail = request.email.toLowerCase().trim(); + + if (_registeredAccounts.containsKey(lowerEmail)) { + return _registeredAccounts[lowerEmail]!.copyWith(isOnline: _mockOnlineState); + } + + final isVolunteer = lowerEmail.contains('volunteer') || lowerEmail.contains('relawan'); + final isPartner = lowerEmail.contains('mitra') || lowerEmail.contains('klinik'); + return PsychologistProfileResponseDto( id: 'counselor-uuid-001', - fullName: 'Sarah Anindya', - titleDegree: 'M.Psi., Psikolog Klinis', + fullName: isVolunteer ? 'Raka Danendra (Relawan Sebaya)' : 'Sarah Anindya', + titleDegree: isVolunteer ? 'Relawan Teman Sebaya' : 'M.Psi., Psikolog Klinis', email: request.email, - strNumber: 'STR-PK-198827364-2024', - association: 'Himpunan Psikologi Indonesia (HIMPSI)', + strNumber: isVolunteer ? 'VOLUNTEER-PEER' : 'STR-PK-198827364-2024', + association: isVolunteer ? 'Komunitas Teman Curhat' : 'Himpunan Psikologi Indonesia (HIMPSI)', specializations: [ 'Kecemasan Dewasa & Burnout', 'Manajemen Stres', 'Konseling Hubungan & Keluarga', ], - yearsOfExperience: 5, - consultationRatePerHour: 200000, + yearsOfExperience: isVolunteer ? 1 : 5, + consultationRatePerHour: isVolunteer ? 0 : 200000, verificationStatus: 'verified', isOnline: _mockOnlineState, profilePhotoUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400', - certificateDocUrl: 'https://eva.nexacode.dev/docs/sample_str.pdf', + certificateDocUrl: isVolunteer ? null : 'https://eva.nexacode.dev/docs/sample_str.pdf', token: 'mock-jwt-token-sarah-counselor-secret-xyz', + counselorRole: isVolunteer + ? 'peer_volunteer' + : isPartner + ? 'professional_partner' + : 'professional_independent', + partnerName: isPartner ? 'Klinik Sehati Sejahtera' : null, + sessionDurationMinutes: 50, + coinsPerSession: isVolunteer ? 0 : (isPartner ? 180 : 150), + coinBalance: isVolunteer ? 0 : 1250, + googleDriveEmail: request.email, ); } @@ -34,8 +54,11 @@ class MockAuthDataSource implements AuthDataSource { Future register( PsychologistRegisterRequestDto request) async { await Future.delayed(const Duration(milliseconds: 800)); - return PsychologistProfileResponseDto( - id: 'counselor-uuid-new', + final isVerified = request.counselorRole == 'peer_volunteer' || + request.counselorRole == 'professional_partner'; + + final dto = PsychologistProfileResponseDto( + id: 'counselor-uuid-${DateTime.now().millisecondsSinceEpoch}', fullName: request.fullName, titleDegree: request.titleDegree, email: request.email, @@ -44,11 +67,20 @@ class MockAuthDataSource implements AuthDataSource { specializations: request.specializations, yearsOfExperience: request.yearsOfExperience, consultationRatePerHour: request.consultationRate, - verificationStatus: 'pending', // Freshly uploaded certificate requires review + verificationStatus: isVerified ? 'verified' : 'pending', isOnline: false, certificateDocUrl: request.certificateDocFileName, - token: 'mock-jwt-pending-verification-token', + token: 'mock-jwt-token-active-${DateTime.now().millisecondsSinceEpoch}', + counselorRole: request.counselorRole, + partnerName: request.partnerName, + sessionDurationMinutes: request.sessionDurationMinutes, + coinsPerSession: request.coinsPerSession, + coinBalance: request.counselorRole == 'peer_volunteer' ? 0 : 1250, + googleDriveEmail: request.email, ); + + _registeredAccounts[request.email.toLowerCase().trim()] = dto; + return dto; } @override @@ -72,6 +104,11 @@ class MockAuthDataSource implements AuthDataSource { isOnline: _mockOnlineState, profilePhotoUrl: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400', token: 'mock-jwt-token-sarah-counselor-secret-xyz', + counselorRole: 'professional_independent', + sessionDurationMinutes: 50, + coinsPerSession: 150, + coinBalance: 1250, + googleDriveEmail: 'sarah.anindya@gmail.com', ); } diff --git a/apps/eva_counselor_mobile/lib/features/auth/data/models/psychologist_auth_dto.dart b/apps/eva_counselor_mobile/lib/features/auth/data/models/psychologist_auth_dto.dart index 4590646..68b8bcc 100644 --- a/apps/eva_counselor_mobile/lib/features/auth/data/models/psychologist_auth_dto.dart +++ b/apps/eva_counselor_mobile/lib/features/auth/data/models/psychologist_auth_dto.dart @@ -28,6 +28,10 @@ class PsychologistRegisterRequestDto { final int yearsOfExperience; final int consultationRate; final String certificateDocFileName; + final String counselorRole; // 'professional_partner', 'professional_independent', 'peer_volunteer' + final String? partnerName; + final int sessionDurationMinutes; + final int coinsPerSession; const PsychologistRegisterRequestDto({ required this.fullName, @@ -40,6 +44,10 @@ class PsychologistRegisterRequestDto { required this.yearsOfExperience, required this.consultationRate, required this.certificateDocFileName, + this.counselorRole = 'professional_independent', + this.partnerName, + this.sessionDurationMinutes = 50, + this.coinsPerSession = 150, }); Map toJson() => { @@ -53,6 +61,10 @@ class PsychologistRegisterRequestDto { 'years_of_experience': yearsOfExperience, 'consultation_rate_per_hour': consultationRate, 'certificate_doc_file_name': certificateDocFileName, + 'counselor_role': counselorRole, + if (partnerName != null) 'partner_name': partnerName, + 'session_duration_minutes': sessionDurationMinutes, + 'coins_per_session': coinsPerSession, }; } @@ -72,6 +84,12 @@ class PsychologistProfileResponseDto { final String? profilePhotoUrl; final String? certificateDocUrl; final String? token; + final String counselorRole; + final String? partnerName; + final int sessionDurationMinutes; + final int coinsPerSession; + final int coinBalance; + final String? googleDriveEmail; const PsychologistProfileResponseDto({ required this.id, @@ -88,6 +106,12 @@ class PsychologistProfileResponseDto { this.profilePhotoUrl, this.certificateDocUrl, this.token, + this.counselorRole = 'professional_independent', + this.partnerName, + this.sessionDurationMinutes = 50, + this.coinsPerSession = 150, + this.coinBalance = 1250, + this.googleDriveEmail, }); factory PsychologistProfileResponseDto.fromJson(Map json) { @@ -111,6 +135,12 @@ class PsychologistProfileResponseDto { profilePhotoUrl: json['profile_photo_url']?.toString(), certificateDocUrl: json['certificate_doc_url']?.toString(), token: json['token']?.toString(), + counselorRole: json['counselor_role']?.toString() ?? 'professional_independent', + partnerName: json['partner_name']?.toString(), + sessionDurationMinutes: json['session_duration_minutes'] as int? ?? 50, + coinsPerSession: json['coins_per_session'] as int? ?? 150, + coinBalance: json['coin_balance'] as int? ?? 1250, + googleDriveEmail: json['google_drive_email']?.toString(), ); } @@ -129,8 +159,62 @@ class PsychologistProfileResponseDto { 'profile_photo_url': profilePhotoUrl, 'certificate_doc_url': certificateDocUrl, if (token != null) 'token': token, + 'counselor_role': counselorRole, + if (partnerName != null) 'partner_name': partnerName, + 'session_duration_minutes': sessionDurationMinutes, + 'coins_per_session': coinsPerSession, + 'coin_balance': coinBalance, + if (googleDriveEmail != null) 'google_drive_email': googleDriveEmail, }; + PsychologistProfileResponseDto copyWith({ + String? id, + String? fullName, + String? titleDegree, + String? email, + String? strNumber, + String? association, + List? specializations, + int? yearsOfExperience, + int? consultationRatePerHour, + String? verificationStatus, + bool? isOnline, + String? profilePhotoUrl, + String? certificateDocUrl, + String? token, + String? counselorRole, + String? partnerName, + int? sessionDurationMinutes, + int? coinsPerSession, + int? coinBalance, + String? googleDriveEmail, + }) { + return PsychologistProfileResponseDto( + id: id ?? this.id, + fullName: fullName ?? this.fullName, + titleDegree: titleDegree ?? this.titleDegree, + email: email ?? this.email, + strNumber: strNumber ?? this.strNumber, + association: association ?? this.association, + specializations: specializations ?? this.specializations, + yearsOfExperience: yearsOfExperience ?? this.yearsOfExperience, + consultationRatePerHour: + consultationRatePerHour ?? this.consultationRatePerHour, + verificationStatus: verificationStatus ?? this.verificationStatus, + isOnline: isOnline ?? this.isOnline, + profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl, + certificateDocUrl: certificateDocUrl ?? this.certificateDocUrl, + token: token ?? this.token, + counselorRole: counselorRole ?? this.counselorRole, + partnerName: partnerName ?? this.partnerName, + sessionDurationMinutes: + sessionDurationMinutes ?? this.sessionDurationMinutes, + coinsPerSession: coinsPerSession ?? this.coinsPerSession, + coinBalance: coinBalance ?? this.coinBalance, + googleDriveEmail: googleDriveEmail ?? this.googleDriveEmail, + ); + } + PsychologistUserEntity toEntity() { VerificationStatus status; switch (verificationStatus.toLowerCase()) { @@ -149,6 +233,23 @@ class PsychologistProfileResponseDto { break; } + CounselorRole roleEnum; + switch (counselorRole.toLowerCase()) { + case 'peer_volunteer': + case 'volunteer': + roleEnum = CounselorRole.peerVolunteer; + break; + case 'professional_partner': + case 'mitra': + roleEnum = CounselorRole.professionalPartner; + break; + case 'professional_independent': + case 'mandiri': + default: + roleEnum = CounselorRole.professionalIndependent; + break; + } + return PsychologistUserEntity( id: id, fullName: fullName, @@ -163,6 +264,12 @@ class PsychologistProfileResponseDto { isOnline: isOnline, profilePhotoUrl: profilePhotoUrl, certificateDocUrl: certificateDocUrl, + role: roleEnum, + partnerName: partnerName, + sessionDurationMinutes: sessionDurationMinutes, + coinsPerSession: coinsPerSession, + coinBalance: coinBalance, + googleDriveEmail: googleDriveEmail ?? 'sarah.anindya@gmail.com', ); } } diff --git a/apps/eva_counselor_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart b/apps/eva_counselor_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart index daa5a2a..168f631 100644 --- a/apps/eva_counselor_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/apps/eva_counselor_mobile/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -36,6 +36,10 @@ class AuthRepositoryImpl implements IAuthRepository { required int yearsOfExperience, required int consultationRate, required String certificateDocFileName, + String counselorRole = 'professional_independent', + String? partnerName, + int sessionDurationMinutes = 50, + int coinsPerSession = 150, }) async { final request = PsychologistRegisterRequestDto( fullName: fullName, @@ -48,6 +52,10 @@ class AuthRepositoryImpl implements IAuthRepository { yearsOfExperience: yearsOfExperience, consultationRate: consultationRate, certificateDocFileName: certificateDocFileName, + counselorRole: counselorRole, + partnerName: partnerName, + sessionDurationMinutes: sessionDurationMinutes, + coinsPerSession: coinsPerSession, ); final dto = await _dataSource.register(request); if (dto.token != null) { diff --git a/apps/eva_counselor_mobile/lib/features/auth/domain/entities/psychologist_user_entity.dart b/apps/eva_counselor_mobile/lib/features/auth/domain/entities/psychologist_user_entity.dart index 57b5f0a..604c13a 100644 --- a/apps/eva_counselor_mobile/lib/features/auth/domain/entities/psychologist_user_entity.dart +++ b/apps/eva_counselor_mobile/lib/features/auth/domain/entities/psychologist_user_entity.dart @@ -1,9 +1,15 @@ enum VerificationStatus { pending, verified, needRevision, rejected } +enum CounselorRole { + professionalPartner, // Relawan Profesional dengan Mitra (Jadwal & koin diatur mitra di web, koin ke mitra) + professionalIndependent, // Relawan Profesional Non-Mitra (Mandiri: atur sesi, menit, tarif koin, redeem koin ke Rp) + peerVolunteer, // Relawan Non-Profesional / Teman Sebaya (Curhat non-kritis, tanpa STR, tanpa SOAP, tanpa koin) +} + class PsychologistUserEntity { final String id; final String fullName; - final String titleDegree; // e.g., 'S.Psi., M.Psi., Psikolog' + final String titleDegree; // e.g., 'S.Psi., M.Psi., Psikolog' or 'Relawan Sebaya' final String email; final String strNumber; // Surat Tanda Registrasi final String association; // e.g. 'HIMPSI' @@ -15,6 +21,16 @@ class PsychologistUserEntity { final String? profilePhotoUrl; final String? certificateDocUrl; + // New role and session/coin system fields + final CounselorRole role; + final String? partnerName; // e.g. 'RS Mitra Sehati' (khusus mitra) + final int sessionDurationMinutes; // durasi 1 sesi dalam menit (misal 50) + final int coinsPerSession; // tarif koin per sesi (misal 150 koin) + final int coinBalance; // saldo koin akun + final String googleDriveEmail; // email drive untuk backup lokal E2EE + final DateTime? lastDriveBackup; + final String autoBackupFrequency; // 'Harian', 'Mingguan', 'Manual' + const PsychologistUserEntity({ required this.id, required this.fullName, @@ -29,7 +45,82 @@ class PsychologistUserEntity { this.isOnline = true, this.profilePhotoUrl, this.certificateDocUrl, + this.role = CounselorRole.professionalIndependent, + this.partnerName, + this.sessionDurationMinutes = 50, + this.coinsPerSession = 150, + this.coinBalance = 1250, + this.googleDriveEmail = 'sarah.anindya@gmail.com', + this.lastDriveBackup, + this.autoBackupFrequency = 'Harian', }); - String get displayName => '$fullName, $titleDegree'; + bool get isVolunteer => role == CounselorRole.peerVolunteer; + bool get isPartner => role == CounselorRole.professionalPartner; + bool get isIndependent => role == CounselorRole.professionalIndependent; + bool get isProfessional => role != CounselorRole.peerVolunteer; + + String get displayName => + isVolunteer ? fullName : '$fullName, $titleDegree'; + + String get roleBadgeLabel { + switch (role) { + case CounselorRole.peerVolunteer: + return 'Relawan Teman Sebaya'; + case CounselorRole.professionalIndependent: + return 'Psikolog Profesional (Mandiri)'; + case CounselorRole.professionalPartner: + return 'Psikolog Mitra • ${partnerName ?? "Klinik Sehati"}'; + } + } + + PsychologistUserEntity copyWith({ + String? id, + String? fullName, + String? titleDegree, + String? email, + String? strNumber, + String? association, + List? specializations, + int? yearsOfExperience, + int? consultationRatePerHour, + VerificationStatus? verificationStatus, + bool? isOnline, + String? profilePhotoUrl, + String? certificateDocUrl, + CounselorRole? role, + String? partnerName, + int? sessionDurationMinutes, + int? coinsPerSession, + int? coinBalance, + String? googleDriveEmail, + DateTime? lastDriveBackup, + String? autoBackupFrequency, + }) { + return PsychologistUserEntity( + id: id ?? this.id, + fullName: fullName ?? this.fullName, + titleDegree: titleDegree ?? this.titleDegree, + email: email ?? this.email, + strNumber: strNumber ?? this.strNumber, + association: association ?? this.association, + specializations: specializations ?? this.specializations, + yearsOfExperience: yearsOfExperience ?? this.yearsOfExperience, + consultationRatePerHour: + consultationRatePerHour ?? this.consultationRatePerHour, + verificationStatus: verificationStatus ?? this.verificationStatus, + isOnline: isOnline ?? this.isOnline, + profilePhotoUrl: profilePhotoUrl ?? this.profilePhotoUrl, + certificateDocUrl: certificateDocUrl ?? this.certificateDocUrl, + role: role ?? this.role, + partnerName: partnerName ?? this.partnerName, + sessionDurationMinutes: + sessionDurationMinutes ?? this.sessionDurationMinutes, + coinsPerSession: coinsPerSession ?? this.coinsPerSession, + coinBalance: coinBalance ?? this.coinBalance, + googleDriveEmail: googleDriveEmail ?? this.googleDriveEmail, + lastDriveBackup: lastDriveBackup ?? this.lastDriveBackup, + autoBackupFrequency: autoBackupFrequency ?? this.autoBackupFrequency, + ); + } } diff --git a/apps/eva_counselor_mobile/lib/features/auth/domain/repositories/auth_repository.dart b/apps/eva_counselor_mobile/lib/features/auth/domain/repositories/auth_repository.dart index 3b7f320..506a826 100644 --- a/apps/eva_counselor_mobile/lib/features/auth/domain/repositories/auth_repository.dart +++ b/apps/eva_counselor_mobile/lib/features/auth/domain/repositories/auth_repository.dart @@ -13,6 +13,10 @@ abstract class IAuthRepository { required int yearsOfExperience, required int consultationRate, required String certificateDocFileName, + String counselorRole = 'professional_independent', + String? partnerName, + int sessionDurationMinutes = 50, + int coinsPerSession = 150, }); Future getCurrentProfile(); Future setOnlineAvailability(bool isOnline); diff --git a/apps/eva_counselor_mobile/lib/features/auth/presentation/providers/auth_provider.dart b/apps/eva_counselor_mobile/lib/features/auth/presentation/providers/auth_provider.dart index 239ad43..c9db2bb 100644 --- a/apps/eva_counselor_mobile/lib/features/auth/presentation/providers/auth_provider.dart +++ b/apps/eva_counselor_mobile/lib/features/auth/presentation/providers/auth_provider.dart @@ -60,6 +60,10 @@ class AuthStateNotifier extends StateNotifier activatePartnerAccount({ + required String email, + required String password, + required String partnerName, + required String fullName, + String? strNumber, + }) async { + state = const AsyncValue.loading(); + try { + final user = await _repository.register( + fullName: fullName, + titleDegree: 'M.Psi., Psikolog Klinis', + email: email, + password: password, + strNumber: strNumber ?? 'STR-MITRA-2026-99', + association: 'Himpunan Psikologi Indonesia (HIMPSI)', + specializations: [ + 'Kecemasan Dewasa', + 'Konseling Hubungan & Keluarga', + 'Manajemen Stres', + ], + yearsOfExperience: 4, + consultationRate: 180000, + certificateDocFileName: 'STR_Mitra_Verified.pdf', + counselorRole: 'professional_partner', + partnerName: partnerName, + sessionDurationMinutes: 50, + coinsPerSession: 180, + ); + state = AsyncValue.data(user); + } catch (e, st) { + state = AsyncValue.error(e, st); + } + } + + void switchRole(CounselorRole newRole, {String? partnerName}) { + final current = state.value; + if (current == null) return; + state = AsyncValue.data(current.copyWith( + role: newRole, + partnerName: partnerName ?? (newRole == CounselorRole.professionalPartner ? 'Klinik Sehati Mitra' : null), + coinsPerSession: newRole == CounselorRole.peerVolunteer ? 0 : current.coinsPerSession, + )); + } + + void updateSessionSettings({ + required int sessionDurationMinutes, + required int coinsPerSession, + }) { + final current = state.value; + if (current == null) return; + state = AsyncValue.data(current.copyWith( + sessionDurationMinutes: sessionDurationMinutes, + coinsPerSession: coinsPerSession, + )); + } + + void upgradeToProfessional({ + required String strNumber, + required String association, + required String certificateDocFileName, + required CounselorRole newRole, + String? partnerName, + int sessionDurationMinutes = 50, + int coinsPerSession = 150, + }) { + final current = state.value; + if (current == null) return; + state = AsyncValue.data(current.copyWith( + strNumber: strNumber, + association: association, + certificateDocUrl: certificateDocFileName, + role: newRole, + partnerName: partnerName, + sessionDurationMinutes: sessionDurationMinutes, + coinsPerSession: coinsPerSession, + verificationStatus: VerificationStatus.verified, + )); + } + + void syncDriveBackup({ + required String email, + required String frequency, + }) { + final current = state.value; + if (current == null) return; + state = AsyncValue.data(current.copyWith( + googleDriveEmail: email, + autoBackupFrequency: frequency, + lastDriveBackup: DateTime.now(), + )); + } + + void redeemCoins(int coins) { + final current = state.value; + if (current == null) return; + final newBalance = (current.coinBalance - coins).clamp(0, 999999); + state = AsyncValue.data(current.copyWith(coinBalance: newBalance)); + } + Future toggleAvailability(bool isOnline) async { final current = state.value; if (current == null) return; try { await _repository.setOnlineAvailability(isOnline); - state = AsyncValue.data(PsychologistUserEntity( - id: current.id, - fullName: current.fullName, - titleDegree: current.titleDegree, - email: current.email, - strNumber: current.strNumber, - association: current.association, - specializations: current.specializations, - yearsOfExperience: current.yearsOfExperience, - consultationRatePerHour: current.consultationRatePerHour, - verificationStatus: current.verificationStatus, - isOnline: isOnline, - profilePhotoUrl: current.profilePhotoUrl, - certificateDocUrl: current.certificateDocUrl, - )); + state = AsyncValue.data(current.copyWith(isOnline: isOnline)); } catch (_) {} } diff --git a/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/partner_counselor_activation_screen.dart b/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/partner_counselor_activation_screen.dart new file mode 100644 index 0000000..ef23e17 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/partner_counselor_activation_screen.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/widgets/custom_button.dart'; +import '../../../../core/widgets/custom_text_field.dart'; +import '../providers/auth_provider.dart'; + +class PartnerCounselorActivationScreen extends ConsumerStatefulWidget { + final String? invitationToken; + final String? prefilledEmail; + final String? partnerName; + final String? fullName; + + const PartnerCounselorActivationScreen({ + super.key, + this.invitationToken, + this.prefilledEmail, + this.partnerName, + this.fullName, + }); + + @override + ConsumerState createState() => + _PartnerCounselorActivationScreenState(); +} + +class _PartnerCounselorActivationScreenState + extends ConsumerState { + final _formKey = GlobalKey(); + + late final TextEditingController _emailCtrl; + late final TextEditingController _nameCtrl; + late final TextEditingController _partnerCtrl; + final _passwordCtrl = TextEditingController(); + + bool _obscurePassword = true; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _emailCtrl = TextEditingController( + text: widget.prefilledEmail ?? 'sarah.lestari@gmail.com', + ); + _nameCtrl = TextEditingController( + text: widget.fullName ?? 'Dr. Sarah Lestari, M.Psi., Psikolog', + ); + _partnerCtrl = TextEditingController( + text: widget.partnerName ?? 'RS Mitra Sehati Jakarta', + ); + } + + @override + void dispose() { + _emailCtrl.dispose(); + _nameCtrl.dispose(); + _partnerCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + void _handleActivate() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + await ref.read(authStateProvider.notifier).activatePartnerAccount( + email: _emailCtrl.text.trim(), + password: _passwordCtrl.text, + partnerName: _partnerCtrl.text.trim(), + fullName: _nameCtrl.text.trim(), + ); + + if (!mounted) return; + setState(() => _isLoading = false); + + _showActivationSuccessDialog(); + } + + void _showActivationSuccessDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + contentPadding: const EdgeInsets.all(24), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: const Color(0xFFF3E8FF), + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFE9D5FF)), + ), + child: const Icon( + Icons.verified_user_rounded, + color: Color(0xFF7E22CE), + size: 42, + ), + ), + const SizedBox(height: 18), + const Text( + 'Akun Mitra Berhasil Diaktifkan!', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + Text( + 'Kata sandi Anda telah berhasil dibuat. Anda terafiliasi dengan ${_partnerCtrl.text} dan kini dapat masuk menggunakan email ${_emailCtrl.text}.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 24), + CustomButton( + label: 'Masuk ke Beranda Konselor', + onPressed: () { + Navigator.of(ctx).pop(); + Navigator.of(context).pushReplacementNamed('/counselor-shell'); + }, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded, color: AppColors.textPrimary), + onPressed: () => Navigator.of(context).pushReplacementNamed('/counselor-login'), + ), + title: Text( + 'Aktivasi Akun Mitra', + style: AppTypography.headingMedium.copyWith(fontSize: 18), + ), + ), + body: SafeArea( + child: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Welcome Banner (Soft Violet & Clean) + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6B21A8), Color(0xFF8B5CF6)], + ), + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: const Color(0xFF7E22CE).withValues(alpha: 0.22), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.mark_email_read_rounded, size: 14, color: Colors.white), + SizedBox(width: 6), + Text( + 'Tautan Undangan Email Terverifikasi', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const Icon(Icons.apartment_rounded, color: Colors.white, size: 22), + ], + ), + const SizedBox(height: 14), + const Text( + 'Selamat Datang di EVA Konselor', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + const Text( + 'Institusi mitra telah mendaftarkan detail profil Anda. Silakan buat kata sandi baru untuk mengamankan akun dan mulai praktik telekonsultasi.', + style: TextStyle(color: Colors.white70, fontSize: 12, height: 1.4), + ), + ], + ), + ), + const SizedBox(height: 20), + + // CARD 1: DETAIL AKUN DARI MITRA (READ-ONLY / PRE-FILLED) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Informasi Akun Didaftarkan Mitra', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF3E8FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Data Resmi Mitra', + style: TextStyle( + color: Color(0xFF7E22CE), + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + CustomTextField( + label: 'Lembaga / RS Mitra Pengundang', + hint: 'Nama Mitra', + controller: _partnerCtrl, + prefixIcon: Icons.apartment_rounded, + readOnly: true, + ), + const SizedBox(height: 14), + CustomTextField( + label: 'Nama Lengkap Psikolog', + hint: 'Nama Psikolog', + controller: _nameCtrl, + prefixIcon: Icons.person_outline_rounded, + readOnly: true, + ), + const SizedBox(height: 14), + CustomTextField( + label: 'Email Terdaftar (Gmail / Akun Mitra)', + hint: 'nama@gmail.com', + controller: _emailCtrl, + prefixIcon: Icons.email_outlined, + readOnly: true, + ), + ], + ), + ), + const SizedBox(height: 20), + + // CARD 2: BUAT KATA SANDI (STEP 4) + const Text( + 'Buat Kata Sandi Akun Baru', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + const Text( + 'Kata sandi ini akan Anda gunakan untuk login mandiri di aplikasi EVA setiap saat.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + const SizedBox(height: 10), + + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + CustomTextField( + label: 'Kata Sandi Baru', + hint: 'Minimal 6 karakter', + controller: _passwordCtrl, + obscureText: _obscurePassword, + prefixIcon: Icons.lock_outline_rounded, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + size: 20, + color: AppColors.textSecondary, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + validator: (v) { + if (v == null || v.isEmpty) return 'Kata sandi wajib diisi'; + if (v.length < 6) return 'Minimal 6 karakter'; + return null; + }, + ), + ], + ), + ), + const SizedBox(height: 16), + + // PROTOCOL INFO CAPSULE + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFAF5FF), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE9D5FF)), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline_rounded, color: Color(0xFF7E22CE), size: 20), + SizedBox(width: 10), + Expanded( + child: Text( + 'Ketentuan Psikolog Mitra: Jadwal praktik & honorarium koin Anda dikelola terpusat oleh administrator Mitra via Web Portal Mitra. Rekam medis SOAP klinis tersimpan secara lokal di perangkat Anda (E2EE 256-bit).', + style: TextStyle( + fontSize: 11, + color: Color(0xFF6B21A8), + height: 1.4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // SUBMIT BUTTON + CustomButton( + label: 'Simpan Kata Sandi & Masuk ke EVA', + isLoading: _isLoading, + onPressed: _handleActivate, + ), + const SizedBox(height: 16), + + Center( + child: TextButton( + onPressed: () { + Navigator.of(context).pushReplacementNamed('/counselor-login'); + }, + child: const Text( + 'Sudah Pernah Aktivasi? Masuk ke Halaman Login', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_login_screen.dart b/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_login_screen.dart index 937b6e1..fbcf93f 100644 --- a/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_login_screen.dart +++ b/apps/eva_counselor_mobile/lib/features/auth/presentation/screens/psychologist_login_screen.dart @@ -49,7 +49,7 @@ class _PsychologistLoginScreenState extends ConsumerState { final _formKey = GlobalKey(); + // Role Category Selection + CounselorRole _selectedRole = CounselorRole.professionalIndependent; + + // Controllers final _fullNameCtrl = TextEditingController(); final _degreeCtrl = TextEditingController(text: 'M.Psi., Psikolog Klinis'); final _emailCtrl = TextEditingController(); @@ -26,7 +30,14 @@ class _PsychologistRegistrationScreenState final _strCtrl = TextEditingController(); final _associationCtrl = TextEditingController(text: 'Himpunan Psikologi Indonesia (HIMPSI)'); - final _rateCtrl = TextEditingController(text: '200000'); + final _partnerNameCtrl = + TextEditingController(text: 'RS Mitra Sehati Jakarta'); + final _volunteerCommunityCtrl = + TextEditingController(text: 'Komunitas Teman Curhat Mahasiswa'); + + // Session & Coin Settings (Khusus Profesional Mandiri) + int _sessionDurationMinutes = 50; + final _coinsPerSessionCtrl = TextEditingController(text: '150'); String? _uploadedFileName; bool _isLoading = false; @@ -36,10 +47,28 @@ class _PsychologistRegistrationScreenState 'Depresi & Suasana Hati', 'Manajemen Stres & Burnout', 'Konseling Remaja & Akademik', - 'Hubungan & Keluarga', - 'Trauma & PTSD', + 'Hubungan & Komunikasi', + 'Trauma & Regulasi Emosi', + 'Quarter-life Crisis', ]; - final Set _selectedSpecializations = {'Kecemasan Dewasa', 'Manajemen Stres & Burnout'}; + final Set _selectedSpecializations = { + 'Kecemasan Dewasa', + 'Manajemen Stres & Burnout', + }; + + @override + void dispose() { + _fullNameCtrl.dispose(); + _degreeCtrl.dispose(); + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + _strCtrl.dispose(); + _associationCtrl.dispose(); + _partnerNameCtrl.dispose(); + _volunteerCommunityCtrl.dispose(); + _coinsPerSessionCtrl.dispose(); + super.dispose(); + } void _handlePickFile() { setState(() { @@ -47,7 +76,7 @@ class _PsychologistRegistrationScreenState }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Dokumen STR berhasil dipilih untuk verifikasi.'), + content: Text('Dokumen STR / Berkas Legalitas berhasil dipilih.'), backgroundColor: AppColors.primary, ), ); @@ -55,10 +84,14 @@ class _PsychologistRegistrationScreenState void _handleSubmit() async { if (!_formKey.currentState!.validate()) return; - if (_uploadedFileName == null) { + + // Check certificate requirement (Only for professionals) + if (_selectedRole != CounselorRole.peerVolunteer && _uploadedFileName == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Mohon lampirkan scan STR/SIPP untuk verifikasi legalitas praktik.'), + content: Text( + 'Mohon lampirkan scan STR/SIPP untuk verifikasi akun profesional.', + ), backgroundColor: AppColors.error, ), ); @@ -66,46 +99,107 @@ class _PsychologistRegistrationScreenState } setState(() => _isLoading = true); + + final roleString = _selectedRole == CounselorRole.peerVolunteer + ? 'peer_volunteer' + : 'professional_independent'; + + final coins = int.tryParse(_coinsPerSessionCtrl.text) ?? 150; + await ref.read(authStateProvider.notifier).register( fullName: _fullNameCtrl.text, - titleDegree: _degreeCtrl.text, + titleDegree: _selectedRole == CounselorRole.peerVolunteer + ? 'Relawan Teman Sebaya' + : _degreeCtrl.text, email: _emailCtrl.text, password: _passwordCtrl.text, - strNumber: _strCtrl.text, - association: _associationCtrl.text, + strNumber: _selectedRole == CounselorRole.peerVolunteer + ? 'VOLUNTEER-PEER' + : _strCtrl.text, + association: _selectedRole == CounselorRole.peerVolunteer + ? _volunteerCommunityCtrl.text + : _associationCtrl.text, specializations: _selectedSpecializations.toList(), - yearsOfExperience: 3, - consultationRate: int.tryParse(_rateCtrl.text) ?? 200000, - certificateDocFileName: _uploadedFileName!, + yearsOfExperience: _selectedRole == CounselorRole.peerVolunteer ? 1 : 3, + consultationRate: _selectedRole == CounselorRole.peerVolunteer + ? 0 + : coins * 1000, + certificateDocFileName: _uploadedFileName ?? 'none', + counselorRole: roleString, + partnerName: null, + sessionDurationMinutes: _sessionDurationMinutes, + coinsPerSession: _selectedRole == CounselorRole.peerVolunteer ? 0 : coins, ); if (!mounted) return; setState(() => _isLoading = false); + _showSuccessDialog(); + } + + void _showSuccessDialog() { + final isVolunteer = _selectedRole == CounselorRole.peerVolunteer; + final title = isVolunteer + ? 'Pendaftaran Relawan Berhasil' + : 'Pendaftaran Profesional Terkirim'; + final message = isVolunteer + ? 'Akun Relawan Teman Sebaya Anda telah aktif. Anda dapat langsung membuka jadwal ketersediaan untuk sesi curhat non-kritis tanpa perlu mengisi SOAP medis.' + : 'Pendaftaran akun Psikolog Mandiri dan berkas STR Anda telah berhasil diunggah. Tarif $_sessionDurationMinutes mnt (${_coinsPerSessionCtrl.text} Koin) siap diaktifkan setelah verifikasi 1x24 jam.'; + final icon = isVolunteer + ? Icons.volunteer_activism_rounded + : Icons.verified_user_rounded; + final iconColor = isVolunteer + ? const Color(0xFF00796B) + : AppColors.primary; + showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Row( - children: const [ - Icon(Icons.verified_user_outlined, color: AppColors.primary), - SizedBox(width: 8), - Text('Pendaftaran Terkirim'), + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + contentPadding: const EdgeInsets.all(24), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon(icon, color: iconColor, size: 40), + ), + const SizedBox(height: 18), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 24), + CustomButton( + label: 'Masuk ke Beranda Konselor', + onPressed: () { + Navigator.of(ctx).pop(); + Navigator.of(context).pushReplacementNamed('/counselor-shell'); + }, + ), ], ), - content: const Text( - 'Pendaftaran akun psikolog dan berkas STR Anda telah berhasil diunggah. Tim klinis EVA akan memverifikasi dokumen Anda dalam 1x24 jam kerja.', - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - Navigator.of(context).pushReplacementNamed('/counselor-shell'); - }, - child: const Text('Buka Dashboard Mitra'), - ), - ], ), ); } @@ -113,52 +207,175 @@ class _PsychologistRegistrationScreenState @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), appBar: AppBar( - title: Text('Pendaftaran Mitra Psikolog', style: AppTypography.headingMedium), + backgroundColor: Colors.transparent, + elevation: 0, + centerTitle: false, + title: Text( + 'Pendaftaran Konselor', + style: AppTypography.headingMedium.copyWith(fontSize: 18), + ), ), body: SafeArea( child: Form( key: _formKey, child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Kredensial Profesional', - style: AppTypography.headingSmall, - ), - const SizedBox(height: 4), - Text( - 'Pastikan data sesuai dengan sertifikasi resmi HIMPSI atau Kemenkes.', - style: AppTypography.bodyMedium, + // Top Header Intro (Clean & Soft) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.diversity_3_rounded, + color: AppColors.primary, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + 'Pilih Peran Layanan Anda', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 2), + Text( + 'Pilih kategori relawan untuk menyesuaikan alur verifikasi & fitur aplikasi.', + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + height: 1.3, + ), + ), + ], + ), + ), + ], + ), ), const SizedBox(height: 16), - GlassCard( + + // 2 PILIHAN PENDAFTARAN MOBILE + 1 KARTU KHUSUS MITRA + _buildCategoryOption( + role: CounselorRole.peerVolunteer, + title: 'Relawan Non-Profesional (Teman Sebaya)', + badge: 'Teman Curhat • Non-Komersial', + badgeColor: const Color(0xFFE0F7F6), + badgeTextColor: const Color(0xFF00796B), + icon: Icons.volunteer_activism_rounded, + description: + 'Berperan sebagai teman curhat & pendengar aktif untuk kasus non-kritis. Tanpa syarat STR, tanpa SOAP medis, dan sukarela tanpa koin.', + ), + const SizedBox(height: 10), + _buildCategoryOption( + role: CounselorRole.professionalIndependent, + title: 'Relawan Profesional Mandiri (Non-Mitra)', + badge: 'Klinis Mandiri • Sistem Koin', + badgeColor: AppColors.primaryContainer, + badgeTextColor: AppColors.primary, + icon: Icons.psychology_rounded, + description: + 'Praktik klinis mandiri untuk menangani kasus komprehensif. Wajib STR. Anda bebas menentukan durasi sesi & tarif koin untuk di-redeem ke Rupiah.', + ), + const SizedBox(height: 12), + + // INFO UNTUK PSIKOLOG MITRA (SINGKAT & ELEGAN) + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + children: const [ + Icon(Icons.info_outline_rounded, size: 16, color: AppColors.textSecondary), + SizedBox(width: 10), + Expanded( + child: Text( + 'Anda psikolog mitra? Daftarkan instansi/mitra Anda melalui Web Portal Mitra (eva.id/mitra) untuk aktivasi akun.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.35), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + + // DYNAMIC NOTICE CALLOUT + _buildRoleNoticeBanner(), + const SizedBox(height: 20), + + // SECTION 1: IDENTITAS UTAMA + const Text( + 'Data Akun & Identitas', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 10), + + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), child: Column( children: [ CustomTextField( - label: 'Nama Lengkap (Tanpa Gelar)', - hint: 'cth: Sarah Anindya', + label: _selectedRole == CounselorRole.peerVolunteer + ? 'Nama Lengkap Panggilan' + : 'Nama Lengkap (Sesuai Ijazah)', + hint: _selectedRole == CounselorRole.peerVolunteer + ? 'cth: Sarah Anindya' + : 'cth: Sarah Anindya (tanpa gelar)', controller: _fullNameCtrl, - prefixIcon: Icons.person_outline, - validator: (v) => v!.isEmpty ? 'Wajib diisi' : null, - ), - const SizedBox(height: 14), - CustomTextField( - label: 'Gelar Akademik & Profesi', - hint: 'cth: M.Psi., Psikolog', - controller: _degreeCtrl, - prefixIcon: Icons.school_outlined, + prefixIcon: Icons.person_outline_rounded, + validator: (v) => + v == null || v.isEmpty ? 'Wajib diisi' : null, ), + if (_selectedRole != CounselorRole.peerVolunteer) ...[ + const SizedBox(height: 14), + CustomTextField( + label: 'Gelar Akademik & Profesi', + hint: 'cth: M.Psi., Psikolog Klinis', + controller: _degreeCtrl, + prefixIcon: Icons.school_outlined, + validator: (v) => + v == null || v.isEmpty ? 'Gelar wajib diisi' : null, + ), + ], const SizedBox(height: 14), CustomTextField( - label: 'Email Profesional', - hint: 'nama@psikolog.eva.id', + label: 'Email Akun Konselor', + hint: 'nama@email.com', controller: _emailCtrl, prefixIcon: Icons.email_outlined, keyboardType: TextInputType.emailAddress, - validator: (v) => v!.isEmpty ? 'Wajib diisi' : null, + validator: (v) => + v == null || v.isEmpty ? 'Email wajib diisi' : null, ), const SizedBox(height: 14), CustomTextField( @@ -166,30 +383,164 @@ class _PsychologistRegistrationScreenState hint: '••••••••', controller: _passwordCtrl, obscureText: true, - prefixIcon: Icons.lock_outline, - validator: (v) => (v?.length ?? 0) < 6 ? 'Min. 6 karakter' : null, - ), - const SizedBox(height: 14), - CustomTextField( - label: 'Nomor STR / SIPP Aktif', - hint: 'cth: STR-PK-198827364-2024', - controller: _strCtrl, - prefixIcon: Icons.badge_outlined, - validator: (v) => v!.isEmpty ? 'Nomor STR/SIPP wajib diisi' : null, - ), - const SizedBox(height: 14), - CustomTextField( - label: 'Tarif Konsultasi per Sesi (Rp)', - hint: '200000', - controller: _rateCtrl, - prefixIcon: Icons.payments_outlined, - keyboardType: TextInputType.number, + prefixIcon: Icons.lock_outline_rounded, + validator: (v) => + (v?.length ?? 0) < 6 ? 'Minimal 6 karakter' : null, ), ], ), ), const SizedBox(height: 20), - Text('Fokus Spesialisasi Klinis', style: AppTypography.headingSmall), + + // SECTION 2: KREDENSIAL PROFESIONAL / RELAWAN + if (_selectedRole == CounselorRole.peerVolunteer) ...[ + const Text( + 'Profil Teman Sebaya & Minat', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + CustomTextField( + label: 'Komunitas / Asal Kampus / Organisasi', + hint: 'cth: BEM Psikologi / Relawan Sebaya', + controller: _volunteerCommunityCtrl, + prefixIcon: Icons.groups_outlined, + ), + ], + ), + ), + ] else ...[ + const Text( + 'Kredensial Profesional Klinis', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + CustomTextField( + label: 'Nomor STR / SIPP Aktif', + hint: 'cth: STR-PK-198827364-2026', + controller: _strCtrl, + prefixIcon: Icons.badge_outlined, + validator: (v) => + v == null || v.isEmpty ? 'Nomor STR wajib diisi' : null, + ), + const SizedBox(height: 14), + CustomTextField( + label: 'Organisasi Profesi', + hint: 'cth: HIMPSI', + controller: _associationCtrl, + prefixIcon: Icons.corporate_fare_outlined, + ), + ], + ), + ), + ], + const SizedBox(height: 20), + + // SECTION 3: PENGATURAN SESI & KOIN (Hanya untuk Profesional Mandiri) + if (_selectedRole == CounselorRole.professionalIndependent) ...[ + const Text( + 'Pengaturan Sesi & Sistem Koin', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + const Text( + 'Tentukan durasi dan tarif koin per sesi. Koin dapat Anda redeem menjadi Rupiah ke rekening bank.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Durasi Standar 1 Sesi Telekonsultasi:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [30, 45, 50, 60].map((mins) { + final isSel = _sessionDurationMinutes == mins; + return ChoiceChip( + label: Text('$mins Menit'), + selected: isSel, + backgroundColor: Colors.white, + selectedColor: AppColors.primaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isSel + ? AppColors.primary.withValues(alpha: 0.35) + : const Color(0xFFECEFF8), + width: 1.0, + ), + ), + labelStyle: TextStyle( + fontSize: 12, + fontWeight: + isSel ? FontWeight.bold : FontWeight.normal, + color: isSel + ? AppColors.primary + : AppColors.textPrimary, + ), + onSelected: (_) { + setState(() => _sessionDurationMinutes = mins); + }, + ); + }).toList(), + ), + const SizedBox(height: 16), + CustomTextField( + label: 'Tarif Koin per Sesi Konsultasi', + hint: '150', + controller: _coinsPerSessionCtrl, + prefixIcon: Icons.monetization_on_rounded, + keyboardType: TextInputType.number, + helperText: '1 Koin = Rp 1.000 (150 Koin ≈ Rp 150.000)', + validator: (v) { + final n = int.tryParse(v ?? ''); + if (n == null || n < 10) return 'Minimal 10 koin'; + return null; + }, + ), + ], + ), + ), + const SizedBox(height: 20), + ], + + // SECTION 4: BIDANG FOKUS / TOPIK + const Text( + 'Fokus Topik / Minat Pendampingan', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), const SizedBox(height: 10), Wrap( spacing: 8, @@ -199,71 +550,112 @@ class _PsychologistRegistrationScreenState return FilterChip( selected: isSelected, label: Text(spec), + backgroundColor: Colors.white, selectedColor: AppColors.primaryContainer, checkmarkColor: AppColors.primary, - labelStyle: AppTypography.bodySmall.copyWith( - color: isSelected ? AppColors.primary : AppColors.textPrimary, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + showCheckmark: isSelected, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isSelected + ? AppColors.primary.withValues(alpha: 0.35) + : const Color(0xFFECEFF8), + width: 1.0, + ), + ), + labelStyle: TextStyle( + fontSize: 12, + color: isSelected + ? AppColors.primary + : AppColors.textPrimary, + fontWeight: + isSelected ? FontWeight.bold : FontWeight.w500, ), onSelected: (val) { setState(() { if (val) { _selectedSpecializations.add(spec); } else { - _selectedSpecializations.remove(spec); + if (_selectedSpecializations.length > 1) { + _selectedSpecializations.remove(spec); + } } }); }, ); }).toList(), ), - const SizedBox(height: 24), - Text('Unggah Berkas Legalitas (STR / SIPP)', style: AppTypography.headingSmall), - const SizedBox(height: 6), - Text( - 'Format PDF atau JPG/PNG (Maks 5MB) untuk diverifikasi oleh Dewan Etik EVA.', - style: AppTypography.bodyMedium, - ), - const SizedBox(height: 12), - InkWell( - onTap: _handlePickFile, - borderRadius: BorderRadius.circular(16), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), - decoration: BoxDecoration( - color: AppColors.primaryContainer.withOpacity(0.4), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.primary.withOpacity(0.3), width: 1.5), - ), - child: Column( - children: [ - Icon( - _uploadedFileName != null - ? Icons.check_circle_outline_rounded - : Icons.cloud_upload_outlined, - size: 38, + const SizedBox(height: 20), + + // SECTION 5: UNGGAH DOKUMEN STR (Hanya untuk Profesional) + if (_selectedRole != CounselorRole.peerVolunteer) ...[ + const Text( + 'Unggah Berkas Legalitas (STR / SIPP)', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 4), + const Text( + 'Format PDF, JPG, atau PNG untuk diverifikasi tim etik EVA.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + const SizedBox(height: 10), + InkWell( + onTap: _handlePickFile, + borderRadius: BorderRadius.circular(18), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all( color: _uploadedFileName != null - ? AppColors.verifiedGreen - : AppColors.primary, + ? AppColors.verifiedGreen.withValues(alpha: 0.5) + : const Color(0xFFD6DBEC), + width: 1.2, ), - const SizedBox(height: 8), - Text( - _uploadedFileName ?? 'Klik untuk Memilih File Scan STR/SIPP', - style: AppTypography.headingSmall.copyWith( - fontSize: 14, + ), + child: Column( + children: [ + Icon( + _uploadedFileName != null + ? Icons.task_alt_rounded + : Icons.cloud_upload_outlined, + size: 36, color: _uploadedFileName != null ? AppColors.verifiedGreen : AppColors.primary, ), - ), - ], + const SizedBox(height: 8), + Text( + _uploadedFileName ?? + 'Pilih Dokumen Scan STR/SIPP (Maks. 5 MB)', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _uploadedFileName != null + ? AppColors.verifiedGreen + : AppColors.primary, + ), + ), + ], + ), ), ), - ), - const SizedBox(height: 32), + const SizedBox(height: 28), + ] else ...[ + const SizedBox(height: 10), + ], + + // CTA SUBMIT BUTTON CustomButton( - label: 'Kirim Pendaftaran & Berkas', + label: _selectedRole == CounselorRole.peerVolunteer + ? 'Daftar Sebagai Relawan Sebaya' + : 'Kirim Pendaftaran & Berkas', isLoading: _isLoading, onPressed: _handleSubmit, ), @@ -275,4 +667,207 @@ class _PsychologistRegistrationScreenState ), ); } + + Widget _buildCategoryOption({ + required CounselorRole role, + required String title, + required String badge, + required Color badgeColor, + required Color badgeTextColor, + required IconData icon, + required String description, + }) { + final isSelected = _selectedRole == role; + + return InkWell( + onTap: () => setState(() => _selectedRole = role), + borderRadius: BorderRadius.circular(20), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? AppColors.primary + : const Color(0xFFECEFF8), + width: isSelected ? 2.0 : 1.0, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.08), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primaryContainer + : const Color(0xFFF4F6FC), + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + badge, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: badgeTextColor, + ), + ), + ), + const Spacer(), + Icon( + isSelected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + color: isSelected + ? AppColors.primary + : AppColors.textLight, + size: 20, + ), + ], + ), + const SizedBox(height: 6), + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildRoleNoticeBanner() { + switch (_selectedRole) { + case CounselorRole.peerVolunteer: + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFE0F7F6), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFB2DFDB)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Icon(Icons.info_outline_rounded, + color: Color(0xFF00796B), size: 20), + SizedBox(width: 10), + Expanded( + child: Text( + 'Sebagai Relawan Teman Sebaya, Anda tidak memerlukan STR dan tidak mengisi catatan rekam medis SOAP. Sesi curhat murni bersifat sukarela tanpa sistem koin. Kasus krisis berisiko tinggi otomatis dialihkan ke Profesional.', + style: TextStyle( + fontSize: 11, + color: Color(0xFF004D40), + height: 1.4, + ), + ), + ), + ], + ), + ); + case CounselorRole.professionalIndependent: + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Icon(Icons.info_outline_rounded, + color: AppColors.primary, size: 20), + SizedBox(width: 10), + Expanded( + child: Text( + 'Praktik mandiri: Slot jadwal yang dibuka langsung otomatis terkonfirmasi saat pasien booking. Koin sesi diterima setelah video call dan SOAP note klinis selesai diisi. Koin dapat di-redeem ke Rupiah kapan saja.', + style: TextStyle( + fontSize: 11, + color: AppColors.primary, + height: 1.4, + ), + ), + ), + ], + ), + ); + case CounselorRole.professionalPartner: + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF3E8FF), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFD8B4FE)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Icon(Icons.info_outline_rounded, + color: Color(0xFF7E22CE), size: 20), + SizedBox(width: 10), + Expanded( + child: Text( + 'Sebagai Psikolog Mitra, jadwal dan alokasi koin dikelola terpusat oleh Mitra melalui Web Portal Mitra. Koin sesi masuk ke kas lembaga mitra Anda.', + style: TextStyle( + fontSize: 11, + color: Color(0xFF581C87), + height: 1.4, + ), + ), + ), + ], + ), + ); + } + } } diff --git a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/providers/counselor_nav_provider.dart b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/providers/counselor_nav_provider.dart index a1c8a22..0de46ef 100644 --- a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/providers/counselor_nav_provider.dart +++ b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/providers/counselor_nav_provider.dart @@ -6,3 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 2: Keuangan (Finances & Payouts) /// 3: Profil (Profile & Practice Settings) final counselorBottomNavIndexProvider = StateProvider((ref) => 0); + +/// Trigger to launch or restart the spotlight coachmark tour on demand. +final triggerCounselorSpotlightTourProvider = StateProvider((ref) => 0); diff --git a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_home_screen.dart b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_home_screen.dart index f67a2eb..60054f4 100644 --- a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_home_screen.dart +++ b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_home_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; import '../../../../core/widgets/status_badge.dart'; -import '../../../../core/widgets/top_toast.dart'; import '../../../auth/presentation/providers/auth_provider.dart'; import '../../../patients/presentation/providers/patient_provider.dart'; import '../providers/counselor_nav_provider.dart'; @@ -17,39 +16,6 @@ class CounselorHomeScreen extends ConsumerStatefulWidget { class _CounselorHomeScreenState extends ConsumerState { int _selectedCalendarIndex = 1; // 1 = Sel 11 (Hari Ini) - int _currentBookingIndex = 0; - late final PageController _bookingPageController = PageController(); - - @override - void dispose() { - _bookingPageController.dispose(); - super.dispose(); - } - - // MULTIPLE PENDING BOOKINGS - final List> _pendingBookings = [ - { - 'id': 'book-1', - 'patientName': 'Maya Saraswati (22 th)', - 'chiefComplaint': 'Burnout Skripsi & Beban Ekspektasi', - 'requestedSchedule': 'Besok Rabu, 12 Nov • 14:00 - 14:50 WIB', - 'expiresIn': '01:45:00', - }, - { - 'id': 'book-2', - 'patientName': 'Dimas Setiawan (27 th)', - 'chiefComplaint': 'Kecemasan Karier & Problem Hubungan', - 'requestedSchedule': 'Kamis, 13 Nov • 10:00 - 10:50 WIB', - 'expiresIn': '00:52:30', - }, - { - 'id': 'book-3', - 'patientName': 'Gita Savira (24 th)', - 'chiefComplaint': 'Insomnia Akut & Serangan Panik', - 'requestedSchedule': 'Jumat, 14 Nov • 16:00 - 16:50 WIB', - 'expiresIn': '01:15:10', - }, - ]; // FULL 7-DAY WEEK (Senin s/d Minggu) final List> _calendarDays = [ @@ -345,23 +311,20 @@ class _CounselorHomeScreenState extends ConsumerState { maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 2), - Row( - children: const [ - Icon(Icons.verified_rounded, size: 13, color: AppColors.verifiedGreen), - SizedBox(width: 4), - Flexible( - child: Text( - 'STR Aktif #4192-PSI', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: AppColors.verifiedGreen, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (user?.isVolunteer == true) + StatusBadge.volunteer('Relawan Teman Sebaya') + else if (user?.isPartner == true) + StatusBadge.proPartner('Psikolog Mitra • ${user?.partnerName ?? "RS Mitra"}') + else + StatusBadge.proIndependent('Psikolog Mandiri'), + if (user?.isProfessional == true) + StatusBadge.coins(user?.coinBalance ?? 1250), ], ), ], @@ -479,23 +442,11 @@ class _CounselorHomeScreenState extends ConsumerState { ), ), ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(10), - ), - child: const Text( - 'Telekonsul', - style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), - ), - ), ], ), const SizedBox(height: 16), - // Patient Photo + Clinical & Emotional Record + // Patient Photo + Info Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -517,8 +468,8 @@ class _CounselorHomeScreenState extends ConsumerState { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( + children: const [ + Text( 'Rian Pratama (24 Thn)', style: TextStyle( color: Colors.white, @@ -526,35 +477,17 @@ class _CounselorHomeScreenState extends ConsumerState { fontWeight: FontWeight.w700, ), ), - const SizedBox(height: 2), - const Text( + SizedBox(height: 3), + Text( 'Generalized Anxiety & Insomnia', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: Colors.white70, fontSize: 11), + style: TextStyle(color: Colors.white70, fontSize: 12), ), - const SizedBox(height: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Text('😟', style: TextStyle(fontSize: 11)), - SizedBox(width: 4), - Flexible( - child: Text( - 'Cemas • DASS-21: 18', - style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + SizedBox(height: 4), + Text( + 'Durasi 50 Menit • Video Call', + style: TextStyle(color: Colors.white60, fontSize: 11), ), ], ), @@ -617,259 +550,29 @@ class _CounselorHomeScreenState extends ConsumerState { ), const SizedBox(height: 18), - // 4. PERMINTAAN KONSULTASI BARU (DISTINCT ACTION CARD DENGAN WARM AMBER STYLING & FOCUSED PAGER) - if (_pendingBookings.isNotEmpty) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: Color(0xFFD97706), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - 'Permintaan Booking (${_pendingBookings.length} Menunggu)', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.textPrimary), + // 4. PROTOKOL KESELAMATAN (KHUSUS RELAWAN) + if (user?.isVolunteer == true) ...[ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFE0F7F6), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFB2DFDB)), + ), + child: Row( + children: const [ + Icon(Icons.shield_outlined, color: Color(0xFF00796B), size: 22), + SizedBox(width: 10), + Expanded( + child: Text( + 'Protokol Keselamatan: Kasus krisis darurat & indikasi risiko tinggi secara otomatis dialihkan sistem ke Psikolog Klinis Berizin. Anda hanya menangani sesi curhat non-kritis.', + style: TextStyle(fontSize: 11, color: Color(0xFF004D40), height: 1.4), ), - ], - ), - if (_pendingBookings.length > 1) - Text( - '${_currentBookingIndex + 1} dari ${_pendingBookings.length}', - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFFB45309)), ), - ], - ), - const SizedBox(height: 10), - SizedBox( - height: 222, - child: PageView.builder( - controller: _bookingPageController, - itemCount: _pendingBookings.length, - onPageChanged: (i) => setState(() => _currentBookingIndex = i), - itemBuilder: (context, idx) { - final b = _pendingBookings[idx]; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFFFBF3), - borderRadius: BorderRadius.circular(22), - border: Border.all(color: const Color(0xFFFFDFB0), width: 1.4), - boxShadow: [ - BoxShadow( - color: const Color(0xFFD97706).withValues(alpha: 0.08), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Header: Tag Konsultasi Baru & Expiry Pill - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: const Color(0xFFFEF3C7), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.mark_email_unread_rounded, size: 14, color: Color(0xFFD97706)), - ), - const SizedBox(width: 6), - const Text( - 'PERMINTAAN KONSULTASI BARU', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w800, - color: Color(0xFFB45309), - letterSpacing: 0.4, - ), - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFFEE2E2), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.timer_outlined, size: 11, color: Color(0xFFDC2626)), - const SizedBox(width: 4), - Text( - 'Sisa ${b['expiresIn'] ?? "01:30:00"}', - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Color(0xFFDC2626), - ), - ), - ], - ), - ), - ], - ), - - // Patient Profile & Problem - Row( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: const Color(0xFFFFEDD5), - child: Text( - (b['patientName'] as String).substring(0, 1), - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Color(0xFFC2410C)), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - b['patientName'] as String, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.textPrimary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 1), - Text( - b['chiefComplaint'] as String, - style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - - // Proposed Schedule Capsule (Clear contrast box) - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFFF1E5D5)), - ), - child: Row( - children: [ - const Icon(Icons.calendar_month_rounded, size: 14, color: AppColors.primary), - const SizedBox(width: 6), - const Text('Jadwal: ', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - Expanded( - child: Text( - b['requestedSchedule'] as String, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 11, color: AppColors.textPrimary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - - // Action Buttons (Terima vs Jadwal Lain) - Row( - children: [ - Expanded( - child: OutlinedButton( - style: OutlinedButton.styleFrom( - backgroundColor: Colors.white, - side: const BorderSide(color: Color(0xFFD6D9E4)), - padding: const EdgeInsets.symmetric(vertical: 9), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () { - final name = b['patientName']; - setState(() { - _pendingBookings.removeAt(idx); - if (_currentBookingIndex >= _pendingBookings.length && _currentBookingIndex > 0) { - _currentBookingIndex = _pendingBookings.length - 1; - } - }); - TopToast.show( - context, - title: 'Opsi Reschedule Dikirim', - message: 'Tawaran opsi jadwal alternatif telah dikirimkan ke $name.', - type: ToastType.info, - ); - }, - child: const Text('Jadwal Lain', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), - ), - ), - const SizedBox(width: 10), - Expanded( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 9), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - icon: const Icon(Icons.check_rounded, size: 16), - label: const Text('Terima Booking', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), - onPressed: () { - final name = b['patientName']; - setState(() { - _pendingBookings.removeAt(idx); - if (_currentBookingIndex >= _pendingBookings.length && _currentBookingIndex > 0) { - _currentBookingIndex = _pendingBookings.length - 1; - } - }); - TopToast.show( - context, - title: 'Booking Diterima', - message: 'Jadwal telekonsultasi dengan $name telah disetujui & slot terkunci.', - type: ToastType.success, - ); - }, - ), - ), - ], - ), - ], - ), - ); - }, + ], ), ), - if (_pendingBookings.length > 1) ...[ - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(_pendingBookings.length, (dotIdx) { - final isActive = dotIdx == _currentBookingIndex; - return AnimatedContainer( - duration: const Duration(milliseconds: 180), - margin: const EdgeInsets.symmetric(horizontal: 3), - width: isActive ? 16 : 6, - height: 5, - decoration: BoxDecoration( - color: isActive ? const Color(0xFFD97706) : const Color(0xFFE2E4EB), - borderRadius: BorderRadius.circular(4), - ), - ); - }), - ), - ], - const SizedBox(height: 18), + const SizedBox(height: 16), ], // 5. HORIZONTAL SCHEDULE MINI-CALENDAR STRIP (LENGKAP 7 HARI, MERAH JIKA LIBUR) diff --git a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_main_shell_screen.dart b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_main_shell_screen.dart index 469a501..c7c135c 100644 --- a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_main_shell_screen.dart +++ b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_main_shell_screen.dart @@ -1,77 +1,154 @@ -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; -import '../../../../core/widgets/top_toast.dart'; -import '../../../auth/presentation/providers/auth_provider.dart'; import '../../../finances/presentation/screens/transaction_history_screen.dart'; import '../../../patients/presentation/screens/patient_list_screen.dart'; -import '../providers/counselor_nav_provider.dart'; +import '../../../profile/presentation/screens/counselor_profile_screen.dart'; +import '../widgets/counselor_spotlight_tour.dart'; import 'counselor_home_screen.dart'; +final counselorBottomNavIndexProvider = StateProvider((ref) => 0); +final triggerCounselorSpotlightTourProvider = StateProvider((ref) => 0); + class CounselorMainShellScreen extends ConsumerStatefulWidget { const CounselorMainShellScreen({super.key}); @override - ConsumerState createState() => - _CounselorMainShellScreenState(); + ConsumerState createState() => _CounselorMainShellScreenState(); } -class _CounselorMainShellScreenState - extends ConsumerState { +class _CounselorMainShellScreenState extends ConsumerState { final List _pages = const [ CounselorHomeScreen(), PatientListScreen(), TransactionHistoryScreen(), - _CounselorProfileTab(), + CounselorProfileScreen(), ]; + final GlobalKey _navBarKey = GlobalKey(); + bool _showSpotlight = false; + int _spotlightStep = 0; + + @override + void initState() { + super.initState(); + _checkFirstTimeTour(); + } + + Future _checkFirstTimeTour() async { + final seen = await CounselorSpotlightTourOverlay.hasSeenTour(); + if (!seen && mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _showSpotlight = true; + }); + } + }); + } + } + + Rect _getNavRect(int index) { + final navBox = _navBarKey.currentContext?.findRenderObject() as RenderBox?; + final shellBox = context.findRenderObject() as RenderBox?; + if (navBox != null && navBox.hasSize && shellBox != null && shellBox.hasSize) { + final navPos = shellBox.globalToLocal(navBox.localToGlobal(Offset.zero)); + final navWidth = navBox.size.width; + final navHeight = navBox.size.height; + final itemWidth = navWidth / 4; + final itemLeft = navPos.dx + index * itemWidth; + return Rect.fromLTWH(itemLeft + 4, navPos.dy + 6, itemWidth - 8, navHeight - 12); + } + final renderBox = context.findRenderObject() as RenderBox?; + final localSize = (renderBox != null && renderBox.hasSize) ? renderBox.size : MediaQuery.of(context).size; + final bottomPadding = MediaQuery.of(context).padding.bottom; + final navWidth = localSize.width - 40; + const navHeight = 68.0; + final navTop = localSize.height - 20 - bottomPadding - navHeight; + final itemWidth = navWidth / 4; + final itemLeft = 20 + index * itemWidth; + return Rect.fromLTWH(itemLeft + 4, navTop + 6, itemWidth - 8, navHeight - 12); + } + + void _nextSpotlightStep() { + if (_spotlightStep < CounselorSpotlightTourOverlay.steps.length - 1) { + setState(() { + _spotlightStep++; + }); + ref.read(counselorBottomNavIndexProvider.notifier).state = _spotlightStep; + } else { + _finishSpotlight(); + } + } + + Future _finishSpotlight() async { + await CounselorSpotlightTourOverlay.markTourSeen(); + if (mounted) { + setState(() { + _showSpotlight = false; + _spotlightStep = 0; + }); + ref.read(counselorBottomNavIndexProvider.notifier).state = 0; + } + } + @override Widget build(BuildContext context) { final currentIndex = ref.watch(counselorBottomNavIndexProvider); - return Scaffold( - extendBody: true, - body: IndexedStack( - index: currentIndex, - children: _pages, - ), - bottomNavigationBar: Container( - margin: const EdgeInsets.only(left: 20, right: 20, bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(28), - boxShadow: const [ - BoxShadow( - color: AppColors.glassShadow, - blurRadius: 24, - offset: Offset(0, 8), + ref.listen(triggerCounselorSpotlightTourProvider, (previous, next) { + if (next > 0 && mounted) { + setState(() { + _spotlightStep = 0; + _showSpotlight = true; + }); + ref.read(counselorBottomNavIndexProvider.notifier).state = 0; + } + }); + + return Stack( + children: [ + Scaffold( + extendBody: true, + body: IndexedStack( + index: currentIndex, + children: _pages, + ), + bottomNavigationBar: Container( + key: _navBarKey, + margin: const EdgeInsets.only(left: 20, right: 20, bottom: 20), + height: 68, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: const Color(0xFFECEFF8), width: 1.2), + boxShadow: const [ + BoxShadow( + color: AppColors.glassShadow, + blurRadius: 24, + offset: Offset(0, 8), + ), + ], ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(28), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - height: 68, - decoration: BoxDecoration( - color: AppColors.glassBackground, - borderRadius: BorderRadius.circular(28), - border: Border.all(color: AppColors.glassBorder, width: 1.2), - ), - child: Row( - children: [ - Expanded(child: _buildNavItem(0, currentIndex, Icons.home_rounded, Icons.home_outlined, 'Beranda')), - Expanded(child: _buildNavItem(1, currentIndex, Icons.people_alt_rounded, Icons.people_alt_outlined, 'Pasien')), - Expanded(child: _buildNavItem(2, currentIndex, Icons.account_balance_wallet_rounded, Icons.account_balance_wallet_outlined, 'Keuangan')), - Expanded(child: _buildNavItem(3, currentIndex, Icons.person_rounded, Icons.person_outline, 'Profil')), - ], - ), + child: Row( + children: [ + Expanded(child: _buildNavItem(0, currentIndex, Icons.home_rounded, Icons.home_outlined, 'Beranda')), + Expanded(child: _buildNavItem(1, currentIndex, Icons.people_alt_rounded, Icons.people_alt_outlined, 'Pasien')), + Expanded(child: _buildNavItem(2, currentIndex, Icons.account_balance_wallet_rounded, Icons.account_balance_wallet_outlined, 'Keuangan')), + Expanded(child: _buildNavItem(3, currentIndex, Icons.person_rounded, Icons.person_outline, 'Profil')), + ], ), ), ), - ), + if (_showSpotlight) + CounselorSpotlightTourOverlay( + currentStep: _spotlightStep, + targetRect: _getNavRect(_spotlightStep), + onNext: _nextSpotlightStep, + onSkip: _finishSpotlight, + ), + ], ); } @@ -110,1972 +187,3 @@ class _CounselorMainShellScreenState ); } } - -class _CounselorProfileTab extends ConsumerStatefulWidget { - const _CounselorProfileTab(); - - @override - ConsumerState<_CounselorProfileTab> createState() => _CounselorProfileTabState(); -} - -class _CounselorProfileTabState extends ConsumerState<_CounselorProfileTab> { - final List _days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; - final Set _activeDays = {'Sen', 'Sel', 'Rab', 'Kam', 'Jum'}; - TimeOfDay _startTime = const TimeOfDay(hour: 9, minute: 0); - TimeOfDay _endTime = const TimeOfDay(hour: 17, minute: 0); - String _workingHours = '09:00 - 17:00 WIB'; - bool _isVacationMode = false; - int _sessionDuration = 50; - int _bufferTime = 15; - int _consultationRate = 200000; - bool _requireManualApproval = true; // Booking confirmation mode - - String _formatTimeOfDay(TimeOfDay tod) { - final h = tod.hour.toString().padLeft(2, '0'); - final m = tod.minute.toString().padLeft(2, '0'); - return '$h:$m'; - } - - String _formatNumber(int n) { - return n.toString().replaceAllMapped( - RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), - (Match m) => '${m[1]}.', - ); - } - - // 1. MANUAL WORKING HOURS PICKER (DENGAN showTimePicker) - Future _showEditWorkingHoursModal() async { - TimeOfDay tempStart = _startTime; - TimeOfDay tempEnd = _endTime; - - await showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => StatefulBuilder( - builder: (ctx, setModalState) => Padding( - padding: EdgeInsets.only( - left: 20, - right: 20, - top: 20, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Ubah Jam Operasional Praktik', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), - ], - ), - const SizedBox(height: 6), - const Text( - 'Pilih jam mulai dan jam selesai praktik secara manual dengan tombol di bawah:', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 18), - - // Manual Time Picker Cards - Row( - children: [ - // JAM MULAI - Expanded( - child: InkWell( - onTap: () async { - final picked = await showTimePicker( - context: ctx, - initialTime: tempStart, - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: const ColorScheme.light( - primary: AppColors.primary, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: AppColors.textPrimary, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setModalState(() => tempStart = picked); - } - }, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Jam Mulai Praktik', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - const SizedBox(height: 6), - Row( - children: [ - const Icon(Icons.access_time_rounded, size: 16, color: AppColors.primary), - const SizedBox(width: 6), - Text( - '${_formatTimeOfDay(tempStart)} WIB', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - ], - ), - ], - ), - ), - ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon(Icons.arrow_forward_rounded, color: AppColors.textLight, size: 18), - ), - // JAM SELESAI - Expanded( - child: InkWell( - onTap: () async { - final picked = await showTimePicker( - context: ctx, - initialTime: tempEnd, - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: const ColorScheme.light( - primary: AppColors.primary, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: AppColors.textPrimary, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setModalState(() => tempEnd = picked); - } - }, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Jam Selesai Praktik', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - const SizedBox(height: 6), - Row( - children: [ - const Icon(Icons.access_time_filled_rounded, size: 16, color: AppColors.primary), - const SizedBox(width: 6), - Text( - '${_formatTimeOfDay(tempEnd)} WIB', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), - ), - ], - ), - ], - ), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // Quick Presets - const Text('Atau Pilih Preset Cepat:', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildPresetTimeChip('09:00 - 17:00 (Standar)', () { - setModalState(() { - tempStart = const TimeOfDay(hour: 9, minute: 0); - tempEnd = const TimeOfDay(hour: 17, minute: 0); - }); - }), - _buildPresetTimeChip('08:00 - 16:00 (Pagi)', () { - setModalState(() { - tempStart = const TimeOfDay(hour: 8, minute: 0); - tempEnd = const TimeOfDay(hour: 16, minute: 0); - }); - }), - _buildPresetTimeChip('13:00 - 21:00 (Sore)', () { - setModalState(() { - tempStart = const TimeOfDay(hour: 13, minute: 0); - tempEnd = const TimeOfDay(hour: 21, minute: 0); - }); - }), - _buildPresetTimeChip('18:00 - 22:00 (Malam)', () { - setModalState(() { - tempStart = const TimeOfDay(hour: 18, minute: 0); - tempEnd = const TimeOfDay(hour: 22, minute: 0); - }); - }), - ], - ), - const SizedBox(height: 20), - - // Save Button - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(46), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - elevation: 0, - ), - onPressed: () { - setState(() { - _startTime = tempStart; - _endTime = tempEnd; - _workingHours = '${_formatTimeOfDay(_startTime)} - ${_formatTimeOfDay(_endTime)} WIB'; - }); - Navigator.pop(ctx); - TopToast.show( - context, - title: 'Jam Praktik Diperbarui', - message: 'Jam operasional berhasil disetel ke $_workingHours', - type: ToastType.success, - ); - }, - child: const Text('Simpan Jam Operasional', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ), - ), - ), - ); - } - - Widget _buildPresetTimeChip(String label, VoidCallback onTap) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFF0F3FA), - borderRadius: BorderRadius.circular(8), - ), - child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), - ), - ); - } - - // 2. PATIENT REVIEWS MODAL - void _showPatientReviewsModal() { - final reviews = [ - { - 'name': 'R**** P****', - 'topic': 'Konseling Kecemasan & Insomnia', - 'rating': 5, - 'date': '2 hari lalu', - 'comment': 'dr. Sarah sangat mendengarkan tanpa menghakimi. Teknik regulasi napas dan CBT yang diajarkan sangat membantu mengendalikan serangan panik saya. Terima kasih banyak!', - 'tag': 'Sangat Empatik', - }, - { - 'name': 'M**** S****', - 'topic': 'Burnout Akademik & Skripsi', - 'rating': 5, - 'date': '5 hari lalu', - 'comment': 'Sesi konsultasi sangat terstruktur dan menenangkan. Saya mendapat sudut pandang baru dan langkah tindak lanjut yang sangat realistis untuk dijalankan.', - 'tag': 'Solutif & Tenang', - }, - { - 'name': 'A**** N****', - 'topic': 'Regulasi Emosi & Hubungan', - 'rating': 5, - 'date': '1 minggu lalu', - 'comment': 'Dokter sangat ramah, hangat, dan memberikan insight mendalam tentang pola komunikasi saya dengan keluarga.', - 'tag': 'Insight Mendalam', - }, - { - 'name': 'D**** K****', - 'topic': 'Stres Kerja & Manajemen Waktu', - 'rating': 4, - 'date': '2 minggu lalu', - 'comment': 'Sangat membantu memetakan prioritas hidup. Telekonsultasi tepat waktu dan rangkuman SOAP Luna sangat memudahkan saya mereview kembali.', - 'tag': 'Tepat Waktu', - }, - ]; - - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => DraggableScrollableSheet( - initialChildSize: 0.8, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (_, scrollController) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: ListView( - controller: scrollController, - children: [ - const SizedBox(height: 12), - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: const Color(0xFFE2E4EB), - borderRadius: BorderRadius.circular(4), - ), - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Ulasan & Penilaian Pasien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)), - IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), - ], - ), - const SizedBox(height: 12), - - // Overall Score Card - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Row( - children: [ - Column( - children: const [ - Text('4.9', style: TextStyle(fontSize: 34, fontWeight: FontWeight.w800, color: AppColors.textPrimary)), - Row( - children: [ - Icon(Icons.star_rounded, color: AppColors.warning, size: 16), - Icon(Icons.star_rounded, color: AppColors.warning, size: 16), - Icon(Icons.star_rounded, color: AppColors.warning, size: 16), - Icon(Icons.star_rounded, color: AppColors.warning, size: 16), - Icon(Icons.star_rounded, color: AppColors.warning, size: 16), - ], - ), - SizedBox(height: 2), - Text('140 Ulasan', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - ], - ), - const SizedBox(width: 24), - Expanded( - child: Column( - children: [ - _buildRatingBar('5 ⭐', 0.92, '130'), - const SizedBox(height: 4), - _buildRatingBar('4 ⭐', 0.06, '8'), - const SizedBox(height: 4), - _buildRatingBar('3 ⭐', 0.02, '2'), - const SizedBox(height: 4), - _buildRatingBar('2 ⭐', 0.0, '0'), - const SizedBox(height: 4), - _buildRatingBar('1 ⭐', 0.0, '0'), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 14), - - // Highlight Tags (Wrap agar tidak overflow) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _buildReviewTagPill('❤️ Sangat Empatik (98%)'), - _buildReviewTagPill('💡 Solutif (95%)'), - _buildReviewTagPill('⏱️ Tepat Waktu (99%)'), - ], - ), - const SizedBox(height: 18), - const Text('Testimoni Pasien (Anonim demi Privasi):', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 10), - - ...reviews.map((r) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - CircleAvatar( - radius: 14, - backgroundColor: AppColors.primaryContainer, - child: const Icon(Icons.person, size: 16, color: AppColors.primary), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(r['name'] as String, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), - Text(r['topic'] as String, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), - ], - ), - ], - ), - Text(r['date'] as String, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - ...List.generate( - r['rating'] as int, - (_) => const Icon(Icons.star_rounded, color: AppColors.warning, size: 14), - ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.secondary, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - r['tag'] as String, - style: const TextStyle(color: AppColors.primary, fontSize: 9, fontWeight: FontWeight.bold), - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - r['comment'] as String, - style: const TextStyle(fontSize: 11, height: 1.4, color: AppColors.textPrimary), - ), - ], - ), - ); - }).toList(), - const SizedBox(height: 20), - ], - ), - ), - ), - ); - } - - Widget _buildRatingBar(String label, double pct, String count) { - return Row( - children: [ - SizedBox(width: 26, child: Text(label, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary))), - const SizedBox(width: 6), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: pct, - backgroundColor: const Color(0xFFE5E9F2), - valueColor: const AlwaysStoppedAnimation(AppColors.warning), - minHeight: 6, - ), - ), - ), - const SizedBox(width: 6), - SizedBox(width: 22, child: Text(count, textAlign: TextAlign.right, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary))), - ], - ); - } - - Widget _buildReviewTagPill(String label) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: const Color(0xFFF0F3FA), - borderRadius: BorderRadius.circular(8), - ), - child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), - ); - } - - // 3. EDIT RATE MODAL (BISA MANUAL INPUT & PILIH REKOMENDASI) - void _showEditRateModal() { - int selectedRate = _consultationRate; - final rateCtrl = TextEditingController(text: selectedRate.toString()); - - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => StatefulBuilder( - builder: (ctx, setModalState) => Padding( - padding: EdgeInsets.only( - left: 20, - right: 20, - top: 20, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Ubah Tarif Konsultasi', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), - ], - ), - const SizedBox(height: 6), - Text( - 'Tentukan tarif per sesi ($_sessionDuration menit). Anda dapat mengetik manual atau memilih rekomendasi:', - style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 16), - - // INPUT MANUAL TARIF DENGAN KEYBOARD ANGKA - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.35), width: 1.4), - ), - child: Row( - children: [ - const Text('Rp', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: AppColors.primary)), - const SizedBox(width: 10), - Expanded( - child: TextField( - controller: rateCtrl, - keyboardType: TextInputType.number, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 19, color: AppColors.textPrimary), - decoration: const InputDecoration( - hintText: '0', - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.symmetric(vertical: 8), - ), - onChanged: (val) { - final parsed = int.tryParse(val.replaceAll(RegExp(r'[^0-9]'), '')); - if (parsed != null && parsed >= 0) { - setModalState(() => selectedRate = parsed); - } - }, - ), - ), - Text('/ $_sessionDuration Mnt', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), - ], - ), - ), - const SizedBox(height: 14), - - const Text('Rekomendasi Tarif Populer:', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), - const SizedBox(height: 8), - - // Rate Option Chips - Wrap( - spacing: 8, - runSpacing: 8, - children: [150000, 200000, 250000, 300000, 350000].map((rate) { - final isSelected = selectedRate == rate; - return GestureDetector( - onTap: () { - setModalState(() { - selectedRate = rate; - rateCtrl.text = rate.toString(); - }); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isSelected ? AppColors.primary : const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isSelected ? AppColors.primary : const Color(0xFFECEFF8), - width: 1.2, - ), - ), - child: Text( - 'Rp ${_formatNumber(rate)}${rate == 200000 ? " (HIMPSI)" : ""}', - style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, - fontSize: 11, - color: isSelected ? Colors.white : AppColors.textPrimary, - ), - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - - // Breakdown Honorarium - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Honorarium Bersih Psikolog (85%):', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - Text('Rp ${_formatNumber((selectedRate * 0.85).toInt())}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.verifiedGreen)), - ], - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Operasional Luna & Enkripsi Medis (15%):', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - Text('Rp ${_formatNumber((selectedRate * 0.15).toInt())}', style: const TextStyle(fontSize: 11, color: AppColors.textSecondary)), - ], - ), - ], - ), - ), - const SizedBox(height: 20), - - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(46), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - elevation: 0, - ), - onPressed: () { - setState(() => _consultationRate = selectedRate); - Navigator.pop(ctx); - TopToast.show( - context, - title: 'Tarif Konsultasi Disimpan', - message: 'Tarif berhasil diatur ke Rp ${_formatNumber(_consultationRate)} / $_sessionDuration menit.', - type: ToastType.success, - ); - }, - child: const Text('Simpan Tarif Baru', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ), - ), - ), - ); - } - - // 4. EDIT DURATION & BUFFER MODAL (DURASI & BUFFER BEBAS PILIH / MANUAL STEPPER) - void _showEditDurationAndBufferModal() { - int tempDuration = _sessionDuration; - int tempBuffer = _bufferTime; - - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => StatefulBuilder( - builder: (ctx, setModalState) => Padding( - padding: EdgeInsets.only( - left: 20, - right: 20, - top: 20, - bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Durasi Sesi & Jeda Istirahat', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), - ], - ), - const SizedBox(height: 6), - const Text( - 'Tentukan durasi panggilan konsultasi dan waktu istirahat antar pasien secara fleksibel:', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 16), - - // DURASI SESI DENGAN COUNTER STEPPER MANUAL & CHIPS - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Durasi Konseling:', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12)), - Container( - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.remove, size: 16, color: AppColors.primary), - visualDensity: VisualDensity.compact, - onPressed: () { - if (tempDuration > 15) { - setModalState(() => tempDuration -= 5); - } - }, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '$tempDuration Menit', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.primary), - ), - ), - IconButton( - icon: const Icon(Icons.add, size: 16, color: AppColors.primary), - visualDensity: VisualDensity.compact, - onPressed: () { - if (tempDuration < 180) { - setModalState(() => tempDuration += 5); - } - }, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [30, 45, 50, 60, 75, 90].map((dur) { - final isSelected = tempDuration == dur; - return GestureDetector( - onTap: () => setModalState(() => tempDuration = dur), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: BoxDecoration( - color: isSelected ? AppColors.primary : const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isSelected ? AppColors.primary : const Color(0xFFECEFF8), - width: 1.2, - ), - ), - child: Text( - '$dur Mnt${dur == 50 ? " (Standar)" : ""}', - style: TextStyle( - color: isSelected ? Colors.white : AppColors.textPrimary, - fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, - fontSize: 11, - ), - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 18), - - // JEDA BUFFER DENGAN COUNTER STEPPER MANUAL & CHIPS - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Jeda Istirahat (Buffer Time):', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12)), - Container( - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.remove, size: 16, color: AppColors.primary), - visualDensity: VisualDensity.compact, - onPressed: () { - if (tempBuffer > 0) { - setModalState(() => tempBuffer -= 5); - } - }, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '$tempBuffer Menit', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.primary), - ), - ), - IconButton( - icon: const Icon(Icons.add, size: 16, color: AppColors.primary), - visualDensity: VisualDensity.compact, - onPressed: () { - if (tempBuffer < 60) { - setModalState(() => tempBuffer += 5); - } - }, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [0, 5, 10, 15, 20, 30].map((buf) { - final isSelected = tempBuffer == buf; - return GestureDetector( - onTap: () => setModalState(() => tempBuffer = buf), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), - decoration: BoxDecoration( - color: isSelected ? AppColors.primary : const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isSelected ? AppColors.primary : const Color(0xFFECEFF8), - width: 1.2, - ), - ), - child: Text( - buf == 0 ? 'Tanpa Jeda' : '$buf Mnt${buf == 15 ? " (Disarankan)" : ""}', - style: TextStyle( - color: isSelected ? Colors.white : AppColors.textPrimary, - fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, - fontSize: 11, - ), - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.primaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), - ), - child: Row( - children: [ - const Icon(Icons.info_outline_rounded, size: 16, color: AppColors.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Total slot waktu: ${tempDuration + tempBuffer} menit ($tempDuration mnt telekonseling + $tempBuffer mnt review SOAP).', - style: const TextStyle(fontSize: 11, color: AppColors.textPrimary), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(46), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - elevation: 0, - ), - onPressed: () { - setState(() { - _sessionDuration = tempDuration; - _bufferTime = tempBuffer; - }); - Navigator.pop(ctx); - TopToast.show( - context, - title: 'Durasi Sesi Diperbarui', - message: 'Sesi ditetapkan ke $_sessionDuration menit dengan jeda istirahat $_bufferTime menit.', - type: ToastType.success, - ); - }, - child: const Text('Simpan Durasi Sesi', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ), - ), - ), - ); - } - - // 5. BANK ACCOUNT MODAL - void _showBankAccountModal() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Rekening Bank Pencairan', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), - ], - ), - const SizedBox(height: 6), - const Text( - 'Rekening terdaftar untuk pencairan otomatis honorarium praktik Anda:', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 16), - - // Card Bank - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF1E3A8A), Color(0xFF2563EB)], - ), - borderRadius: BorderRadius.circular(18), - boxShadow: [ - BoxShadow( - color: const Color(0xFF1E3A8A).withValues(alpha: 0.25), - blurRadius: 14, - offset: const Offset(0, 6), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'BCA (Bank Central Asia)', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Text('UTAMA', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)), - ), - ], - ), - const SizedBox(height: 18), - const Text( - '•••• •••• •••• 8192', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 2), - ), - const SizedBox(height: 14), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ - Text('dr. Sarah Jenkins, M.Psi', style: TextStyle(color: Colors.white70, fontSize: 12)), - Text('TERVERIFIKASI ✅', style: TextStyle(color: Color(0xFF6EE7B7), fontSize: 10, fontWeight: FontWeight.bold)), - ], - ), - ], - ), - ), - const SizedBox(height: 14), - - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Row( - children: const [ - Icon(Icons.schedule_rounded, size: 16, color: AppColors.primary), - SizedBox(width: 8), - Expanded( - child: Text( - 'Pencairan dana otomatis diproses setiap hari Jumat pukul 15:00 WIB ke rekening di atas tanpa potongan biaya transfer.', - style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.3), - ), - ), - ], - ), - ), - const SizedBox(height: 18), - - OutlinedButton.icon( - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFFD6D9E4)), - minimumSize: const Size.fromHeight(44), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - icon: const Icon(Icons.edit_note_rounded, size: 18, color: AppColors.primary), - label: const Text('Ubah Rekening Bank', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.primary)), - onPressed: () { - Navigator.pop(ctx); - TopToast.show( - context, - title: 'Verifikasi Rekening', - message: 'Permintaan perubahan rekening bank memerlukan verifikasi KTP & SIPP (1x24 jam).', - type: ToastType.info, - ); - }, - ), - const SizedBox(height: 10), - ], - ), - ), - ); - } - - // 6. STR & SIPP CREDENTIALS MODAL - void _showStrCredentialsModal() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Kredensial SIPP & STR Psikolog', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), - ], - ), - const SizedBox(height: 6), - const Text( - 'Kredensial klinis resmi Anda yang terverifikasi di Kementerian Kesehatan & HIMPSI:', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 16), - - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.verifiedBg, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.verifiedGreen.withValues(alpha: 0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: const [ - Text('Surat Tanda Registrasi (STR)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.verifiedGreen)), - Icon(Icons.verified_rounded, color: AppColors.verifiedGreen, size: 18), - ], - ), - const SizedBox(height: 6), - const Text('Nomor: 4192-PSI-2024', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - const SizedBox(height: 2), - const Text('Penerbit: KTKI (Konsil Tenaga Kesehatan Indonesia)', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - const SizedBox(height: 2), - const Text('Masa Berlaku: 18 Oktober 2029 (Aktif)', style: TextStyle(fontSize: 11, color: AppColors.verifiedGreen, fontWeight: FontWeight.w600)), - ], - ), - ), - const SizedBox(height: 10), - - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text('Surat Izin Praktik Psikologi (SIPP)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.primary)), - SizedBox(height: 6), - Text('Nomor: 503/SIPP/DKS/2023', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - SizedBox(height: 2), - Text('Wilayah Layanan: Nasional (Telekonsultasi)', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - SizedBox(height: 2), - Text('Keanggotaan HIMPSI: Aktif (#HM-9214)', style: TextStyle(fontSize: 11, color: AppColors.textPrimary, fontWeight: FontWeight.w600)), - ], - ), - ), - const SizedBox(height: 18), - - OutlinedButton( - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFFD6D9E4)), - minimumSize: const Size.fromHeight(44), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () { - Navigator.pop(ctx); - TopToast.show( - context, - title: 'Perpanjangan Kredensial', - message: 'Upload pembaruan STR/SIPP melalui portal web admin psikolog.', - type: ToastType.info, - ); - }, - child: const Text('Perbarui Dokumen Kredensial', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.primary)), - ), - const SizedBox(height: 10), - ], - ), - ), - ); - } - - // 7. SECURITY & PRIVACY MODAL - void _showSecuritySettingsModal() { - bool biometric = true; - bool twoFactor = true; - bool maskPatient = true; - - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => StatefulBuilder( - builder: (ctx, setModalState) => Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Keamanan & Privasi Klinis', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), - ], - ), - const SizedBox(height: 6), - const Text( - 'Perlindungan data pasien sesuai standar kerahasiaan medis & HIPAA:', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 14), - - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Kunci Biometrik (Face ID / Sidik Jari)', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), - subtitle: const Text('Wajib autentikasi sebelum membuka rekam medis pasien', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - value: biometric, - activeColor: AppColors.primary, - onChanged: (val) => setModalState(() => biometric = val), - ), - const Divider(height: 1, color: Color(0xFFF0F2F8)), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Verifikasi 2 Langkah (2FA)', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), - subtitle: const Text('Kode OTP saat login di perangkat baru', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - value: twoFactor, - activeColor: AppColors.primary, - onChanged: (val) => setModalState(() => twoFactor = val), - ), - const Divider(height: 1, color: Color(0xFFF0F2F8)), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('Masking Nama Pasien di Notifikasi', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), - subtitle: const Text('Sembunyikan nama terang pasien pada notifikasi layar kunci', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - value: maskPatient, - activeColor: AppColors.primary, - onChanged: (val) => setModalState(() => maskPatient = val), - ), - const SizedBox(height: 18), - - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(44), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - elevation: 0, - ), - onPressed: () { - Navigator.pop(ctx); - TopToast.show( - context, - title: 'Keamanan Diperbarui', - message: 'Pengaturan keamanan rekam medis berhasil disimpan.', - type: ToastType.success, - ); - }, - child: const Text('Simpan Pengaturan', style: TextStyle(fontWeight: FontWeight.bold)), - ), - const SizedBox(height: 10), - ], - ), - ), - ), - ); - } - - // 8. ETHICAL GUIDELINES MODAL - void _showEthicalGuidelinesModal() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('Panduan Etik & SOP HIMPSI', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), - IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), - ], - ), - const SizedBox(height: 8), - const Text( - 'Standar Operasional Prosedur Layanan Telekonseling Luna:', - style: TextStyle(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 14), - _buildEthicItem('1. Kerahasiaan & Batas Etik', 'Data rekam medis SOAP terenkripsi end-to-end dan hanya dapat diakses psikolog penanggung jawab.'), - const SizedBox(height: 8), - _buildEthicItem('2. Protokol De-eskalasi Krisis', 'Jika skor DASS-21 atau deteksi Luna AI menunjukkan risiko self-harm, psikolog wajib mengaktifkan hotline darurat.'), - const SizedBox(height: 8), - _buildEthicItem('3. Dokumentasi SOAP Tepat Waktu', 'Psikolog diharapkan mereview dan memverifikasi draft auto-SOAP Luna maksimal 24 jam setelah sesi selesai.'), - const SizedBox(height: 16), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(44), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - elevation: 0, - ), - onPressed: () => Navigator.pop(ctx), - child: const Text('Tutup', style: TextStyle(fontWeight: FontWeight.bold)), - ), - const SizedBox(height: 10), - ], - ), - ), - ); - } - - Widget _buildEthicItem(String title, String desc) { - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.textPrimary)), - const SizedBox(height: 2), - Text(desc, style: const TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.3)), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final authState = ref.watch(authStateProvider); - final user = authState.value; - - return Scaffold( - backgroundColor: const Color(0xFFF8F9FE), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. Profile Banner Card (Modern Health App Styling) - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: const Color(0xFFECEFF8)), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.04), - blurRadius: 18, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - children: [ - Row( - children: [ - CircleAvatar( - radius: 34, - backgroundColor: AppColors.primaryContainer, - backgroundImage: user?.profilePhotoUrl != null - ? NetworkImage(user!.profilePhotoUrl!) - : null, - child: user?.profilePhotoUrl == null - ? const Icon(Icons.person, size: 34, color: AppColors.primary) - : null, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.displayName ?? 'dr. Sarah Jenkins, M.Psi', - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - const SizedBox(height: 2), - Text( - user?.email ?? 'sarah.jenkins@psikolog.eva.id', - style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), - ), - const SizedBox(height: 6), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.verifiedBg, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'STR: ${user?.strNumber ?? "4192-PSI-2024"}', - style: const TextStyle( - color: AppColors.verifiedGreen, - fontWeight: FontWeight.bold, - fontSize: 11, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - const SizedBox(height: 18), - - // Stats Bar (Rating, Sessions, Response Rate) - BISA DIKLIK MENUJU ULASAN! - Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - InkWell( - onTap: _showPatientReviewsModal, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - child: _buildStatItem('⭐ 4.9', '140 Ulasan >'), - ), - ), - Container(width: 1, height: 28, color: const Color(0xFFE2E5F0)), - _buildStatItem('38', 'Sesi Bulan Ini'), - Container(width: 1, height: 28, color: const Color(0xFFE2E5F0)), - _buildStatItem('100%', 'Respon Booking'), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // CARD REVIW & KEPUASAN PASIEN - InkWell( - onTap: _showPatientReviewsModal, - borderRadius: BorderRadius.circular(20), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.secondary, - borderRadius: BorderRadius.circular(14), - ), - child: const Icon(Icons.star_rounded, color: AppColors.primary, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text('Kepuasan Pasien • 4.9 / 5.0', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - SizedBox(height: 2), - Text('Lihat rincian 140 ulasan & testimoni konseling Anda', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), - ], - ), - ), - const Icon(Icons.chevron_right_rounded, color: AppColors.textLight, size: 22), - ], - ), - ), - ), - const SizedBox(height: 20), - - // 2. SISTEM PENERIMAAN BOOKING PASIEN (MANUAL CONFIRM VS AUTO) - const Text('Sistem Penerimaan Booking Pasien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - const SizedBox(height: 4), - const Text( - 'Tentukan bagaimana reservasi jadwal konsultasi baru dari pasien diproses oleh sistem.', - style: TextStyle(fontSize: 11, color: AppColors.textSecondary), - ), - const SizedBox(height: 10), - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Column( - children: [ - // Opsi 1: Menunggu Persetujuan Psikolog - InkWell( - onTap: () => setState(() => _requireManualApproval = true), - borderRadius: BorderRadius.circular(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - _requireManualApproval ? Icons.radio_button_checked : Icons.radio_button_off, - color: _requireManualApproval ? AppColors.primary : AppColors.textLight, - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text('Menunggu Persetujuan Psikolog (Disarankan)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - SizedBox(height: 2), - Text( - 'Setiap pasien booking, Anda mendapat notifikasi & berhak menyetujui atau mengajukan opsi jadwal lain.', - style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4), - ), - ], - ), - ), - ], - ), - ), - const Divider(height: 20, color: Color(0xFFF0F2F8)), - - // Opsi 2: Otomatis Diterima (Instan) - InkWell( - onTap: () => setState(() => _requireManualApproval = false), - borderRadius: BorderRadius.circular(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - !_requireManualApproval ? Icons.radio_button_checked : Icons.radio_button_off, - color: !_requireManualApproval ? AppColors.primary : AppColors.textLight, - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text('Otomatis Diterima (Instan Booking)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - SizedBox(height: 2), - Text( - 'Slot konsultasi langsung terkunci otomatis jika pembayaran pasien telah terkonfirmasi.', - style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 20), - - // 3. JADWAL PRAKTIK & OPERASIONAL - const Text('Hari & Jam Praktik Online', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - const SizedBox(height: 10), - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Hari Praktik Aktif:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), - const SizedBox(height: 10), - - // HARI PRAKTIK AKTIF: CUSTOM DAY SELECTION PILLS (LEGA & BORDER LEMBUT, TIDAK DEMPET & TANPA GARIS TEGAS) - Wrap( - spacing: 10, - runSpacing: 10, - children: _days.map((day) { - final isActive = _activeDays.contains(day); - return GestureDetector( - onTap: () { - setState(() { - if (isActive) { - if (_activeDays.length > 1) _activeDays.remove(day); - } else { - _activeDays.add(day); - } - }); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), - decoration: BoxDecoration( - color: isActive ? AppColors.primary : const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isActive ? AppColors.primary : const Color(0xFFECEFF8), - width: 1.2, - ), - boxShadow: isActive - ? [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.18), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isActive) ...[ - const Icon(Icons.check_rounded, color: Colors.white, size: 13), - const SizedBox(width: 4), - ], - Text( - day, - style: TextStyle( - color: isActive ? Colors.white : AppColors.textPrimary, - fontWeight: isActive ? FontWeight.bold : FontWeight.w600, - fontSize: 12, - ), - ), - ], - ), - ), - ); - }).toList(), - ), - const Divider(height: 24, color: Color(0xFFF0F2F8)), - - // Jam Praktik (Dapat Diedit Manual) - InkWell( - onTap: _showEditWorkingHoursModal, - borderRadius: BorderRadius.circular(10), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: const [ - Icon(Icons.access_time_rounded, size: 16, color: AppColors.primary), - SizedBox(width: 6), - Text('Jam Operasional Praktik', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), - ], - ), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: AppColors.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _workingHours, - style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 11), - ), - ), - const SizedBox(width: 6), - const Icon(Icons.edit_outlined, size: 15, color: AppColors.primary), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 14), - - // Durasi Sesi & Buffer Time (KEDUANYA BISA DIKLIK & DIEDIT) - Row( - children: [ - Expanded( - child: InkWell( - onTap: _showEditDurationAndBufferModal, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Durasi Sesi', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), - const SizedBox(height: 2), - Text('$_sessionDuration Menit', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - ], - ), - const Icon(Icons.tune_rounded, size: 14, color: AppColors.primary), - ], - ), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: InkWell( - onTap: _showEditDurationAndBufferModal, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFF7F8FD), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFECEFF8)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Jeda Buffer', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), - const SizedBox(height: 2), - Text('$_bufferTime Menit', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - ], - ), - const Icon(Icons.tune_rounded, size: 14, color: AppColors.primary), - ], - ), - ), - ), - ), - ], - ), - const Divider(height: 24, color: Color(0xFFF0F2F8)), - - // Mode Libur (Vacation Mode) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text('Mode Libur Sementara (Cuti)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), - SizedBox(height: 2), - Text('Tutup sementara pemesanan slot jadwal baru', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), - ], - ), - Switch.adaptive( - value: _isVacationMode, - activeColor: AppColors.warning, - onChanged: (val) { - setState(() => _isVacationMode = val); - }, - ), - ], - ), - ], - ), - ), - const SizedBox(height: 20), - - // 4. HONORARIUM & TARIF (CLEAN MODERN CARDS) - const Text('Honorarium & Keuangan Praktik', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - const SizedBox(height: 10), - - _buildCleanMenuCard( - icon: Icons.payments_outlined, - iconColor: AppColors.verifiedGreen, - iconBg: AppColors.verifiedBg, - title: 'Tarif Konsultasi', - subtitle: 'Rp ${_formatNumber(_consultationRate)} per $_sessionDuration menit', - trailingBadge: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppColors.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Rp ${_formatNumber(_consultationRate)}', - style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 11), - ), - const SizedBox(width: 4), - const Icon(Icons.edit_outlined, size: 12, color: AppColors.primary), - ], - ), - ), - onTap: _showEditRateModal, - ), - - _buildCleanMenuCard( - icon: Icons.account_balance_outlined, - iconColor: const Color(0xFF2563EB), - iconBg: const Color(0xFFEFF6FF), - title: 'Rekening Bank Pencairan', - subtitle: 'BCA • ****8192 (dr. Sarah Jenkins)', - trailingBadge: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppColors.verifiedBg, - borderRadius: BorderRadius.circular(8), - ), - child: const Text('Terverifikasi ✅', style: TextStyle(color: AppColors.verifiedGreen, fontWeight: FontWeight.bold, fontSize: 10)), - ), - onTap: _showBankAccountModal, - ), - - const SizedBox(height: 10), - - // 5. LEGALITAS & KEAMANAN KLINIS (CLEAN MODERN CARDS) - const Text('Legalitas & Keamanan Praktik', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - const SizedBox(height: 10), - - _buildCleanMenuCard( - icon: Icons.verified_user_outlined, - iconColor: AppColors.verifiedGreen, - iconBg: AppColors.verifiedBg, - title: 'Kredensial SIPP & STR Psikolog', - subtitle: 'STR #4192-PSI-2024 • KTKI & HIMPSI', - trailingBadge: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppColors.verifiedBg, - borderRadius: BorderRadius.circular(8), - ), - child: const Text('Aktif 2029', style: TextStyle(color: AppColors.verifiedGreen, fontWeight: FontWeight.bold, fontSize: 10)), - ), - onTap: _showStrCredentialsModal, - ), - - _buildCleanMenuCard( - icon: Icons.shield_outlined, - iconColor: AppColors.primary, - iconBg: AppColors.secondary, - title: 'Keamanan Rekam Medis & Privasi', - subtitle: 'Biometrik FaceID & Enkripsi AES-256', - trailingBadge: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: AppColors.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: const Text('2FA Aktif', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 10)), - ), - onTap: _showSecuritySettingsModal, - ), - - _buildCleanMenuCard( - icon: Icons.menu_book_outlined, - iconColor: const Color(0xFFD97706), - iconBg: const Color(0xFFFEF3C7), - title: 'Panduan Etik & SOP HIMPSI', - subtitle: 'Kode etik layanan telekonseling & penanganan krisis', - trailingBadge: const SizedBox.shrink(), - onTap: _showEthicalGuidelinesModal, - ), - const SizedBox(height: 24), - - // 6. LOGOUT - GestureDetector( - onTap: () async { - await ref.read(authStateProvider.notifier).logout(); - if (context.mounted) { - Navigator.of(context).pushReplacementNamed('/counselor-login'); - } - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 14), - decoration: BoxDecoration( - color: AppColors.highRiskBg, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.logout, color: AppColors.error, size: 18), - SizedBox(width: 8), - Text( - 'Keluar dari Akun Konselor', - style: TextStyle(color: AppColors.error, fontWeight: FontWeight.bold, fontSize: 13), - ), - ], - ), - ), - ), - const SizedBox(height: 80), - ], - ), - ), - ), - ); - } - - Widget _buildCleanMenuCard({ - required IconData icon, - required Color iconColor, - required Color iconBg, - required String title, - required String subtitle, - required Widget trailingBadge, - required VoidCallback onTap, - }) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: const Color(0xFFECEFF8)), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.02), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(18), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), - child: Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: iconBg, - borderRadius: BorderRadius.circular(13), - ), - child: Icon(icon, color: iconColor, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.textPrimary)), - const SizedBox(height: 2), - Text(subtitle, style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis), - ], - ), - ), - const SizedBox(width: 8), - trailingBadge, - const SizedBox(width: 4), - const Icon(Icons.chevron_right_rounded, color: AppColors.textLight, size: 20), - ], - ), - ), - ), - ), - ); - } - - Widget _buildStatItem(String val, String label) { - return Column( - children: [ - Text(val, style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 14, color: AppColors.textPrimary)), - const SizedBox(height: 2), - Text(label, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), - ], - ); - } -} diff --git a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_notification_screen.dart b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_notification_screen.dart index d6866c3..d89d909 100644 --- a/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_notification_screen.dart +++ b/apps/eva_counselor_mobile/lib/features/dashboard/presentation/screens/counselor_notification_screen.dart @@ -16,7 +16,7 @@ class _CounselorNotificationScreenState extends State hasSeenTour() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(prefsKey) ?? false; + } catch (_) { + return false; + } + } + + static Future markTourSeen() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(prefsKey, true); + } catch (_) {} + } + + static const List steps = [ + SpotlightStepModel( + index: 0, + title: 'Beranda Praktik Konseling', + description: 'Kelola status kesiapan online & pantau jadwal telekonseling aktif. Pasien booking langsung terkonfirmasi otomatis.', + icon: Icons.home_rounded, + accentColor: AppColors.primary, + ), + SpotlightStepModel( + index: 1, + title: 'Rekam Medis & SOAP', + description: 'Akses rekam medis dengan persetujuan pasien (Consent Gating), grafik DASS-21, transkrip dialog, dan dokumen SOAP.', + icon: Icons.people_alt_rounded, + accentColor: Color(0xFF0D9488), + ), + SpotlightStepModel( + index: 2, + title: 'Keuangan & Saldo Koin', + description: 'Koin otomatis bertambah setelah sesi dan verifikasi SOAP selesai. Tarik koin ke rekening bank (1 Koin = Rp 1.000).', + icon: Icons.account_balance_wallet_rounded, + accentColor: Color(0xFFD97706), + ), + SpotlightStepModel( + index: 3, + title: 'Profil, Drive E2EE & Sertifikat', + description: 'Atur durasi sesi, cadangkan rekam medis terenkripsi ke Google Drive pribadi, dan unduh sertifikat resmi pencapaian.', + icon: Icons.shield_rounded, + accentColor: Color(0xFF7C3AED), + ), + ]; + + @override + Widget build(BuildContext context) { + final step = steps[currentStep.clamp(0, steps.length - 1)]; + final isLast = step.index == steps.length - 1; + final size = MediaQuery.of(context).size; + final navLabels = ['Beranda', 'Pasien', 'Keuangan', 'Profil']; + + return Material( + color: Colors.transparent, + child: Stack( + fit: StackFit.expand, + children: [ + // 1. LIGHTWEIGHT DARK SCRIM WITH CUT-OUT HOLE (ZERO BLUR, ZERO GPU STALL) + CustomPaint( + size: size, + painter: _SpotlightHolePainter(targetRect: targetRect), + ), + + // 2. TAP BACKGROUND TO ADVANCE + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onNext, + ), + ), + + // 3. THE PROMINENT ROUNDED HIGHLIGHT BOX (CRISP, 100% SHARP) + Positioned( + left: targetRect.left, + top: targetRect.top, + width: targetRect.width, + height: targetRect.height, + child: Container( + decoration: BoxDecoration( + color: step.accentColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white, width: 2.5), + boxShadow: [ + BoxShadow( + color: step.accentColor.withValues(alpha: 0.7), + blurRadius: 18, + spreadRadius: 2, + ), + const BoxShadow( + color: Colors.black38, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(step.icon, color: Colors.white, size: 21), + const SizedBox(height: 2), + Text( + navLabels[step.index], + style: const TextStyle( + color: Colors.white, + fontSize: 10.5, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ), + ), + ), + + // 4. FLOATING COACHMARK TOOLTIP CARD + Positioned( + bottom: (size.height - targetRect.top) + 14, + left: 20, + right: 20, + child: CounselorSpotlightCard( + step: step, + totalSteps: steps.length, + isLast: isLast, + onNext: onNext, + onSkip: onSkip, + ), + ), + + // 5. TRIANGLE POINTER + Positioned( + left: targetRect.center.dx - 9, + bottom: (size.height - targetRect.top) + 6, + child: CustomPaint( + size: const Size(18, 9), + painter: _TrianglePointerPainter(color: Colors.white), + ), + ), + ], + ), + ); + } +} + +class _SpotlightHolePainter extends CustomPainter { + final Rect targetRect; + final double radius; + final Color overlayColor; + + _SpotlightHolePainter({ + required this.targetRect, + this.radius = 16.0, + this.overlayColor = const Color(0xDD0A0F1D), + }); + + @override + void paint(Canvas canvas, Size size) { + if (size.width <= 0 || size.height <= 0) return; + + final paint = Paint() + ..color = overlayColor + ..style = PaintingStyle.fill; + + if (targetRect.isEmpty || targetRect.width <= 0 || targetRect.height <= 0) { + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint); + return; + } + + final path = Path() + ..fillType = PathFillType.evenOdd + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) + ..addRRect(RRect.fromRectAndRadius(targetRect, Radius.circular(radius))); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _SpotlightHolePainter oldDelegate) { + return oldDelegate.targetRect != targetRect || oldDelegate.overlayColor != overlayColor; + } +} + +class _TrianglePointerPainter extends CustomPainter { + final Color color; + + _TrianglePointerPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final path = Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width / 2, size.height) + ..close(); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant _TrianglePointerPainter oldDelegate) => oldDelegate.color != color; +} diff --git a/apps/eva_counselor_mobile/lib/features/finances/presentation/screens/transaction_history_screen.dart b/apps/eva_counselor_mobile/lib/features/finances/presentation/screens/transaction_history_screen.dart index fef958a..426d464 100644 --- a/apps/eva_counselor_mobile/lib/features/finances/presentation/screens/transaction_history_screen.dart +++ b/apps/eva_counselor_mobile/lib/features/finances/presentation/screens/transaction_history_screen.dart @@ -4,6 +4,8 @@ import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/custom_button.dart'; import '../../../../core/widgets/status_badge.dart'; import '../../../../core/widgets/top_toast.dart'; +import '../../../auth/domain/entities/psychologist_user_entity.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; import '../../domain/entities/transaction_entity.dart'; import '../providers/finance_provider.dart'; @@ -19,8 +21,8 @@ class _TransactionHistoryScreenState extends ConsumerState { String _selectedFilter = 'Semua'; - void _showPayoutModal(BuildContext context, int availableBalance) { - final amountCtrl = TextEditingController(text: availableBalance.toString()); + void _showRedeemCoinModal(BuildContext context, int availableCoins) { + final coinAmountCtrl = TextEditingController(text: '150'); final bankCtrl = TextEditingController(text: 'BCA'); final accountCtrl = TextEditingController(text: '8192039182'); final nameCtrl = TextEditingController(text: 'Sarah Anindya'); @@ -35,6 +37,9 @@ class _TransactionHistoryScreenState ), builder: (ctx) => StatefulBuilder( builder: (context, setModalState) { + final requestedCoins = int.tryParse(coinAmountCtrl.text) ?? 0; + final estimatedRp = requestedCoins * 1000; + return Padding( padding: EdgeInsets.only( left: 20, @@ -49,7 +54,16 @@ class _TransactionHistoryScreenState Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Tarik Honorarium Konsultasi', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + Row( + children: const [ + Icon(Icons.monetization_on_rounded, color: Color(0xFFC2410C), size: 22), + SizedBox(width: 8), + Text( + 'Redeem Koin ke Rupiah', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.of(ctx).pop(), @@ -58,39 +72,93 @@ class _TransactionHistoryScreenState ), const SizedBox(height: 6), Text( - 'Saldo siap ditarik: Rp ${_formatRp(availableBalance)}', - style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 13), + 'Saldo Koin Siap Tukar: $availableCoins Koin (1 Koin = Rp 1.000)', + style: const TextStyle( + color: Color(0xFFC2410C), + fontWeight: FontWeight.bold, + fontSize: 12, + ), ), const SizedBox(height: 16), - _buildModalInput('Nominal Penarikan (Rp)', amountCtrl, isNumber: true), - const SizedBox(height: 12), + + // Live Rupiah Conversion Preview + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFFEDD5)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Estimasi Rupiah Diterima:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + Text( + 'Rp ${_formatRp(estimatedRp)}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFFC2410C), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + + _buildModalInput( + 'Jumlah Koin yang Ditukar (Min. 50 Koin)', + coinAmountCtrl, + isNumber: true, + onChanged: (_) => setModalState(() {}), + ), + const SizedBox(height: 10), _buildModalInput('Nama Bank Tujuan', bankCtrl), - const SizedBox(height: 12), + const SizedBox(height: 10), _buildModalInput('Nomor Rekening', accountCtrl, isNumber: true), - const SizedBox(height: 12), + const SizedBox(height: 10), _buildModalInput('Nama Pemilik Rekening (Sesuai KTP)', nameCtrl), const SizedBox(height: 20), + CustomButton( - label: 'Konfirmasi Penarikan Dana', + label: 'Konfirmasi Pencairan Rupiah', isLoading: isSubmitting, onPressed: () async { + if (requestedCoins < 50 || requestedCoins > availableCoins) { + TopToast.show( + context, + title: 'Jumlah Koin Tidak Valid', + message: 'Jumlah koin minimal 50 dan tidak melebihi saldo.', + type: ToastType.error, + ); + return; + } + setModalState(() => isSubmitting = true); final repo = ref.read(financeRepositoryProvider); await repo.requestPayout( - amount: int.tryParse(amountCtrl.text) ?? 0, + amount: estimatedRp, bankName: bankCtrl.text, accountNumber: accountCtrl.text, accountHolderName: nameCtrl.text, ); + + ref.read(authStateProvider.notifier).redeemCoins(requestedCoins); ref.invalidate(financialSummaryProvider); ref.invalidate(transactionsListProvider); + if (!ctx.mounted) return; Navigator.of(ctx).pop(); if (!context.mounted) return; + TopToast.show( context, - title: 'Pencairan Berhasil Diajukan', - message: 'Permintaan transfer honorarium sebesar Rp ${_formatRp(int.tryParse(amountCtrl.text) ?? 0)} sedang diproses.', + title: 'Redeem Koin Diajukan', + message: + 'Penukaran $requestedCoins Koin (Rp ${_formatRp(estimatedRp)}) sedang ditransfer ke rekening ${bankCtrl.text}.', type: ToastType.success, ); }, @@ -103,11 +171,196 @@ class _TransactionHistoryScreenState ); } - Widget _buildModalInput(String label, TextEditingController ctrl, {bool isNumber = false}) { + void _showUpgradeModal(BuildContext context) { + final strCtrl = TextEditingController(); + final assocCtrl = TextEditingController(text: 'HIMPSI'); + final partnerCtrl = TextEditingController(); + CounselorRole targetRole = CounselorRole.professionalIndependent; + String? pickedFile; + bool isUpgrading = false; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Upgrade ke Psikolog Profesional', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 4), + const Text( + 'Daftarkan STR/SIPP Anda untuk membuka akses sistem koin honorarium dan rekam medis SOAP klinis.', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + + // Role Choice + Row( + children: [ + Expanded( + child: ChoiceChip( + label: const Text('Praktik Mandiri'), + selected: targetRole == CounselorRole.professionalIndependent, + onSelected: (_) => setModalState( + () => targetRole = CounselorRole.professionalIndependent), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ChoiceChip( + label: const Text('Dengan Mitra'), + selected: targetRole == CounselorRole.professionalPartner, + onSelected: (_) => setModalState( + () => targetRole = CounselorRole.professionalPartner), + ), + ), + ], + ), + const SizedBox(height: 12), + + _buildModalInput('Nomor STR / SIPP Aktif', strCtrl), + const SizedBox(height: 10), + _buildModalInput('Organisasi Profesi', assocCtrl), + if (targetRole == CounselorRole.professionalPartner) ...[ + const SizedBox(height: 10), + _buildModalInput('Nama Lembaga Mitra', partnerCtrl), + ], + const SizedBox(height: 12), + + // Upload doc + InkWell( + onTap: () { + setModalState(() { + pickedFile = 'STR_Scan_Verified_${DateTime.now().year}.pdf'; + }); + }, + borderRadius: BorderRadius.circular(14), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + pickedFile != null ? Icons.check_circle : Icons.upload_file, + size: 18, + color: pickedFile != null + ? AppColors.verifiedGreen + : AppColors.primary, + ), + const SizedBox(width: 8), + Text( + pickedFile ?? 'Unggah Scan Berkas STR (PDF/JPG)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: pickedFile != null + ? AppColors.verifiedGreen + : AppColors.primary, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + + CustomButton( + label: 'Verifikasi & Upgrade Sekarang', + isLoading: isUpgrading, + onPressed: () async { + if (strCtrl.text.isEmpty) { + TopToast.show( + context, + title: 'Nomor STR Wajib Diisi', + message: 'Mohon masukkan nomor STR/SIPP Anda.', + type: ToastType.error, + ); + return; + } + + setModalState(() => isUpgrading = true); + ref.read(authStateProvider.notifier).upgradeToProfessional( + strNumber: strCtrl.text, + association: assocCtrl.text, + certificateDocFileName: pickedFile ?? 'STR_Uploaded.pdf', + newRole: targetRole, + partnerName: targetRole == CounselorRole.professionalPartner + ? partnerCtrl.text + : null, + ); + + await Future.delayed(const Duration(milliseconds: 500)); + if (!ctx.mounted) return; + Navigator.of(ctx).pop(); + if (!context.mounted) return; + + TopToast.show( + context, + title: 'Akun Berhasil Di-Upgrade!', + message: + 'Selamat! Akun Anda kini aktif sebagai Psikolog Profesional dengan akses dompet koin.', + type: ToastType.success, + ); + }, + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildModalInput( + String label, + TextEditingController ctrl, { + bool isNumber = false, + ValueChanged? onChanged, + }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), const SizedBox(height: 4), Container( decoration: BoxDecoration( @@ -117,6 +370,7 @@ class _TransactionHistoryScreenState child: TextField( controller: ctrl, keyboardType: isNumber ? TextInputType.number : TextInputType.text, + onChanged: onChanged, style: const TextStyle(fontSize: 13), decoration: const InputDecoration( border: InputBorder.none, @@ -137,9 +391,200 @@ class _TransactionHistoryScreenState @override Widget build(BuildContext context) { - final summaryAsync = ref.watch(financialSummaryProvider); + final authUser = ref.watch(authStateProvider).value; final txListAsync = ref.watch(transactionsListProvider); + // KASUS 1: RELAWAN NON-PROFESIONAL (VOLUNTEER) GATED + if (authUser?.isVolunteer == true) { + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.05), + blurRadius: 20, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFE0F7F6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.lock_rounded, + color: Color(0xFF00796B), + size: 42, + ), + ), + const SizedBox(height: 18), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFE0F7F6), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Relawan Teman Sebaya • Non-Profit', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Color(0xFF00796B), + ), + ), + ), + const SizedBox(height: 14), + const Text( + 'Menu Keuangan Khusus Profesional', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + const Text( + 'Layanan Teman Sebaya bersifat murni kemanusiaan & sukarela tanpa sistem transaksi koin.\n\nUntuk membuka dompet koin, menentukan tarif sesi telekonsultasi, dan merekrut pasien klinis, silakan daftarkan sertifikasi STR/SIPP Anda.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 24), + CustomButton( + label: 'Upgrade ke Psikolog Profesional', + icon: Icons.verified_user_rounded, + onPressed: () => _showUpgradeModal(context), + ), + ], + ), + ), + ), + ), + ), + ); + } + + // KASUS 2: PROFESIONAL DENGAN MITRA (KOIN DIATUR & MASUK KE KANTONG MITRA DI WEB) + if (authUser?.isPartner == true) { + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Partner Notice Banner + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6B21A8), Color(0xFF9333EA)], + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: const Color(0xFF7E22CE).withValues(alpha: 0.25), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Mitra: ${authUser?.partnerName ?? "Klinik Sehati"}', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + const Icon(Icons.apartment_rounded, color: Colors.white, size: 20), + ], + ), + const SizedBox(height: 14), + const Text( + 'Sistem Keuangan Dikelola Web Mitra', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + const Text( + 'Seluruh koin sesi telekonsultasi Anda disalurkan langsung ke kas lembaga mitra via Web Portal Mitra.', + style: TextStyle(color: Colors.white70, fontSize: 12, height: 1.4), + ), + const SizedBox(height: 18), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: const [ + _MitraStatItem(label: 'Sesi Bulan Ini', value: '38 Sesi'), + _MitraStatItem(label: 'Koin Mitra Terakumulasi', value: '5.700 Koin'), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + const Text( + 'Riwayat Sesi Konsultasi Mitra', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(height: 12), + _buildTransactionsList(txListAsync, isPartner: true), + ], + ), + ), + ), + ); + } + + // KASUS 3: PROFESIONAL MANDIRI (SISTEM KOIN & REDEEM RUPIAH) + final coins = authUser?.coinBalance ?? 1250; + final rateCoins = authUser?.coinsPerSession ?? 150; + final sessionMins = authUser?.sessionDurationMinutes ?? 50; + return Scaffold( backgroundColor: const Color(0xFFF8F9FE), body: SafeArea( @@ -148,131 +593,162 @@ class _TransactionHistoryScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 1. Balance Overview Card (Soft Indigo Gradient) - summaryAsync.when( - data: (summary) { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF4B53BB), Color(0xFF6B72E5)], - ), - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: const Color(0xFF4B53BB).withValues(alpha: 0.25), - blurRadius: 20, - offset: const Offset(0, 8), + // 1. Balance Overview Card (Sistem Koin Emas & Periwinkle) + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF3F46B8), Color(0xFF5B63E6)], + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: const Color(0xFF4B53BB).withValues(alpha: 0.25), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Flexible( + child: Text( + 'Dompet Koin Konselor', + style: TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Rate: $rateCoins Koin / $sessionMins mnt', + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 10), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 10, + runSpacing: 6, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, children: [ - const Text( - 'Saldo Tersedia (Siap Tarik)', - style: TextStyle(color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w500), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), + const Icon(Icons.monetization_on_rounded, color: Color(0xFFFFD54F), size: 28), + const SizedBox(width: 6), + Text( + '$coins Koin', + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, ), - child: const Text('BCA Terhubung', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), ), ], ), - const SizedBox(height: 8), - Text( - 'Rp ${_formatRp(summary.availableBalance)}', - style: const TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 16), Container( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(14), + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(8), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Menunggu Kliring', style: TextStyle(color: Colors.white70, fontSize: 10)), - const SizedBox(height: 2), - Text( - 'Rp ${_formatRp(summary.pendingClearingBalance)}', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), - ), - ], - ), - Container(width: 1, height: 24, color: Colors.white24), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Total Bulan Ini', style: TextStyle(color: Colors.white70, fontSize: 10)), - const SizedBox(height: 2), - Text( - 'Rp ${_formatRp(summary.totalEarningsThisMonth)}', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), - ), - ], - ), - ], + child: Text( + '≈ Rp ${_formatRp(coins * 1000)}', + style: const TextStyle( + color: Color(0xFFC2410C), + fontSize: 11, + fontWeight: FontWeight.bold, + ), ), ), - const SizedBox(height: 16), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: AppColors.primary, - minimumSize: const Size.fromHeight(46), - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Menunggu Selesai SOAP', style: TextStyle(color: Colors.white70, fontSize: 10)), + SizedBox(height: 2), + Text('150 Koin', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + ], + ), + Container(width: 1, height: 24, color: Colors.white24), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Total Koin Bulan Ini', style: TextStyle(color: Colors.white70, fontSize: 10)), + const SizedBox(height: 2), + Text('${coins + 2000} Koin', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + ], ), - icon: const Icon(Icons.arrow_upward_rounded, size: 18), - label: const Text('Tarik Dana ke Rekening', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - onPressed: () => _showPayoutModal(context, summary.availableBalance), + ], + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: AppColors.primary, + minimumSize: const Size.fromHeight(46), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: const Icon(Icons.currency_exchange_rounded, size: 18), + label: const FittedBox( + fit: BoxFit.scaleDown, + child: Text( + 'Redeem Koin ke Rupiah (Bank Transfer)', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), ), - ], + ), + onPressed: () => _showRedeemCoinModal(context, coins), ), - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Text('Error: $e'), + ], + ), ), const SizedBox(height: 24), - // 2. Filter Pills - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + // 2. Filter Pills (Separated to Column to avoid horizontal overflow) + Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Riwayat Transaksi', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + const Text('Riwayat Koin & Transaksi', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + const SizedBox(height: 10), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: ['Semua', 'Selesai', 'Penarikan'].map((f) { + children: ['Semua', 'Koin Masuk', 'Redeem'].map((f) { final isSel = _selectedFilter == f; return Padding( - padding: const EdgeInsets.only(left: 6), + padding: const EdgeInsets.only(right: 8), child: GestureDetector( onTap: () => setState(() => _selectedFilter = f), child: AnimatedContainer( duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration( color: isSel ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(16), @@ -296,104 +772,8 @@ class _TransactionHistoryScreenState ), const SizedBox(height: 12), - // 3. Transactions List (Soft Cards, No Harsh Lines) - txListAsync.when( - data: (transactions) { - final filtered = transactions.where((t) { - if (_selectedFilter == 'Selesai') { - return t.status == TransactionStatus.completed; - } - if (_selectedFilter == 'Penarikan') { - return t.status == TransactionStatus.payoutRequested; - } - return true; - }).toList(); - - if (filtered.isEmpty) { - return Container( - padding: const EdgeInsets.all(24), - alignment: Alignment.center, - child: const Text('Tidak ada riwayat transaksi.', style: TextStyle(color: AppColors.textSecondary)), - ); - } - - return Column( - children: filtered.map((t) { - final isPayout = t.status == TransactionStatus.payoutRequested; - - return Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: const Color(0xFFECEFF8)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.02), - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), - child: Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: isPayout ? AppColors.pendingBg : AppColors.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - isPayout ? Icons.account_balance_outlined : Icons.videocam_outlined, - color: isPayout ? AppColors.pendingAmber : AppColors.primary, - size: 20, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t.patientName, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - const SizedBox(height: 2), - Text( - '${t.sessionType} • ${t.date.day}/${t.date.month}/${t.date.year}', - style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${isPayout ? '-' : '+'}Rp ${_formatRp(t.netEarnings)}', - style: TextStyle( - fontWeight: FontWeight.w800, - fontSize: 13, - color: isPayout ? AppColors.textPrimary : AppColors.verifiedGreen, - ), - ), - const SizedBox(height: 3), - isPayout - ? StatusBadge.pending('Diproses') - : StatusBadge.verified('Selesai'), - ], - ), - ], - ), - ); - }).toList(), - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Text('Error: $e'), - ), + // 3. Transactions List + _buildTransactionsList(txListAsync, isPartner: false), const SizedBox(height: 80), ], ), @@ -401,4 +781,146 @@ class _TransactionHistoryScreenState ), ); } + + Widget _buildTransactionsList(AsyncValue> txListAsync, {required bool isPartner}) { + return txListAsync.when( + data: (transactions) { + final filtered = transactions.where((t) { + if (_selectedFilter == 'Koin Masuk') { + return t.status == TransactionStatus.completed; + } + if (_selectedFilter == 'Redeem') { + return t.status == TransactionStatus.payoutRequested; + } + return true; + }).toList(); + + if (filtered.isEmpty) { + return Container( + padding: const EdgeInsets.all(24), + alignment: Alignment.center, + child: const Text('Tidak ada riwayat transaksi.', style: TextStyle(color: AppColors.textSecondary)), + ); + } + + return Column( + children: filtered.map((t) { + final isPayout = t.status == TransactionStatus.payoutRequested; + final coinsEarned = (t.netEarnings / 1000).round(); + + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: isPayout + ? const Color(0xFFFFF7ED) + : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isPayout ? Icons.currency_exchange_rounded : Icons.videocam_rounded, + color: isPayout ? const Color(0xFFC2410C) : AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.patientName, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(height: 2), + Text( + '${t.sessionType} • ${t.date.day}/${t.date.month}/${t.date.year}', + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + isPartner + ? '+$coinsEarned Koin' + : isPayout + ? '-$coinsEarned Koin' + : '+$coinsEarned Koin', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 12, + color: isPayout ? const Color(0xFFC2410C) : AppColors.verifiedGreen, + ), + overflow: TextOverflow.ellipsis, + ), + if (isPartner) + const Text( + '(Kas Mitra)', + style: TextStyle(fontSize: 10, color: Color(0xFF7E22CE), fontWeight: FontWeight.bold), + ) + else if (isPayout) + Text( + 'Rp ${_formatRp(t.netEarnings)}', + style: const TextStyle(fontSize: 10, color: Color(0xFFC2410C), fontWeight: FontWeight.w600), + ), + const SizedBox(height: 3), + isPayout + ? StatusBadge.pending('Ditransfer') + : StatusBadge.verified('SOAP Terisi'), + ], + ), + ), + ], + ), + ); + }).toList(), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Text('Error: $e'), + ); + } +} + +class _MitraStatItem extends StatelessWidget { + final String label; + final String value; + + const _MitraStatItem({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white70, fontSize: 10)), + const SizedBox(height: 2), + Text(value, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)), + ], + ); + } } diff --git a/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_detail_consent_screen.dart b/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_detail_consent_screen.dart index a3597b8..84c3720 100644 --- a/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_detail_consent_screen.dart +++ b/apps/eva_counselor_mobile/lib/features/patients/presentation/screens/patient_detail_consent_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/custom_button.dart'; import '../../../../core/widgets/status_badge.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; import '../providers/patient_provider.dart'; class PatientDetailConsentScreen extends ConsumerStatefulWidget { @@ -20,6 +21,41 @@ class _PatientDetailConsentScreenState int _activeTabIndex = 0; // 0 = Rekam Emosional, 1 = Catatan SOAP bool _isRequestingConsent = false; bool _sessionUnlocked = false; + bool _showTranscript = true; + + // Transcript sample for detailed review + final List> _sampleTranscript = [ + { + 'time': '00:03', + 'speaker': 'Pasien', + 'text': 'Halo Mbak Sarah, selamat pagi. Terima kasih sudah menerima jadwal saya.', + }, + { + 'time': '00:18', + 'speaker': 'Psikolog', + 'text': 'Selamat pagi Rian. Sama-sama. Bagaimana kabar dan istirahat Anda semalam?', + }, + { + 'time': '00:42', + 'speaker': 'Pasien', + 'text': 'Tidur saya masih kurang nyenyak. Dada terasa sesak dan tegang tiap kali teringat revisi skripsi bab 4 yang kemarin kena kritik keras pembimbing.', + }, + { + 'time': '01:25', + 'speaker': 'Psikolog', + 'text': 'Sensasi sesak dan tegang itu adalah reaksi fisik yang alami saat otak mempersepsikan situasi tersebut sebagai ancaman besar. Mari kita identifikasi pikiran otomatis apa yang langsung muncul saat menerima pesan pembimbing?', + }, + { + 'time': '02:08', + 'speaker': 'Pasien', + 'text': 'Pikiran saya langsung: "Aku tidak mampu, aku bakal terlambat lulus, dan aku mengecewakan orang tua saya yang sudah berkorban banyak."', + }, + { + 'time': '03:15', + 'speaker': 'Psikolog', + 'text': 'Bagus sekali Anda bisa mengidentifikasinya dengan jelas. Ada dua distorsi kognitif di sini: Catastrophizing (membayangkan skenario terburuk) dan Emotional Reasoning. Di sesi ini, kita akan melatih restrukturisasi pikiran tersebut.', + }, + ]; // SOAP Note Form final _subjCtrl = TextEditingController(); @@ -99,9 +135,14 @@ class _PatientDetailConsentScreenState _planCtrl.clear(); }); + final authUser = ref.read(authStateProvider).value; + final rewardText = authUser?.isProfessional == true + ? 'Catatan klinis SOAP berhasil disimpan. +${authUser?.coinsPerSession ?? 150} Koin dikreditkan ke dompet Anda!' + : 'Catatan klinis SOAP berhasil disimpan.'; + ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Catatan klinis SOAP berhasil disimpan.'), + SnackBar( + content: Text(rewardText), backgroundColor: AppColors.verifiedGreen, ), ); @@ -111,6 +152,8 @@ class _PatientDetailConsentScreenState Widget build(BuildContext context) { final patientAsync = ref.watch(patientDetailProvider(widget.patientId)); final notesAsync = ref.watch(patientNotesProvider(widget.patientId)); + final authUser = ref.watch(authStateProvider).value; + final isVolunteer = authUser?.isVolunteer == true; return Scaffold( backgroundColor: const Color(0xFFF8F9FE), @@ -130,9 +173,11 @@ class _PatientDetailConsentScreenState body: patientAsync.when( data: (patient) { return SafeArea( - child: Column( - children: [ - // Soft Patient Header Card (No harsh borders) + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Soft Patient Header Card (No harsh borders) Container( margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), padding: const EdgeInsets.all(16), @@ -358,7 +403,9 @@ class _PatientDetailConsentScreenState : null, ), child: Text( - 'Catatan SOAP (Luna AI)', + isVolunteer + ? 'Curhat Sebaya (Non-SOAP)' + : 'Catatan SOAP & Transkrip', textAlign: TextAlign.center, style: TextStyle( fontSize: 12, @@ -374,24 +421,21 @@ class _PatientDetailConsentScreenState ), const SizedBox(height: 6), - // Tab Content Area - Expanded( + // Tab Content Area (Scrolls naturally with the page) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: _activeTabIndex == 0 - ? SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: patient.hasConsent - ? _buildConsentGrantedContent(patient) - : _buildConsentLockedGuard(patient), - ) - : SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: _buildSoapTabContent(patient, notesAsync), - ), + ? (patient.hasConsent + ? _buildConsentGrantedContent(patient) + : _buildConsentLockedGuard(patient)) + : _buildSoapTabContent(patient, notesAsync), ), + const SizedBox(height: 32), ], ), - ); - }, + ), + ); + }, loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), ), @@ -555,7 +599,78 @@ class _PatientDetailConsentScreenState // TAB 2: SOAP TAB CONTENT Widget _buildSoapTabContent(dynamic patient, AsyncValue> notesAsync) { final schedule = patient.upcomingSchedule; + final authUser = ref.watch(authStateProvider).value; + // 1. JIKA VOLUNTEER (TEMAN SEBAYA): TIDAK PERLU MENGISI SOAP + if (authUser?.isVolunteer == true) { + return Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFE0F7F6), + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.volunteer_activism_rounded, + color: Color(0xFF00796B), + size: 40, + ), + ), + const SizedBox(height: 16), + const Text( + 'Relawan Teman Sebaya (Bebas SOAP)', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + const Text( + 'Sebagai Relawan Teman Sebaya, Anda tidak perlu mengisi catatan SOAP medis. Rekam medis SOAP klinis secara hukum hanya diperuntukkan bagi Psikolog Berizin STR.\n\nFokus peran Anda adalah pendengar suportif & teman curhat non-kritis. Jika terdapat indikasi bahaya krisis, sistem otomatis mengalihkan pasien ke Psikolog Profesional.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + children: const [ + Icon(Icons.workspace_premium_rounded, color: AppColors.primary, size: 20), + SizedBox(width: 10), + Expanded( + child: Text( + 'Ingin membuka akses SOAP klinis & sistem koin? Upgrade ke Psikolog Profesional di menu Profil Anda.', + style: TextStyle(fontSize: 11, color: AppColors.primary, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ], + ), + ); + } + + // 2. JIKA PROFESIONAL: FORM SOAP LENGKAP + TRANSKRIP return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -596,6 +711,10 @@ class _PatientDetailConsentScreenState ), const SizedBox(height: 14), + // DETAIL TRANSKRIP PERCAKAPAN SESI (ACCORDION) + _buildTranscriptCard(), + const SizedBox(height: 14), + // SOAP INPUTS (Soft, No Harsh Outlines) Container( padding: const EdgeInsets.all(16), @@ -616,7 +735,7 @@ class _PatientDetailConsentScreenState _buildSoftInput('P (Plan) - Rencana Terapi & PR Pasien', _planCtrl), const SizedBox(height: 16), CustomButton( - label: 'Simpan & Finalisasi Rekam Medis', + label: 'Simpan & Finalisasi Rekam Medis (+${authUser?.coinsPerSession ?? 150} Koin)', icon: Icons.check_circle_outline_rounded, isLoading: _isSavingNote, onPressed: _handleSaveSoapNote, @@ -654,7 +773,7 @@ class _PatientDetailConsentScreenState 'Sesi: ${note.createdAt.day}/${note.createdAt.month}/${note.createdAt.year}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), ), - StatusBadge.verified('Terdokumentasi'), + StatusBadge.verified('Terdokumentasi & Valid'), ], ), const SizedBox(height: 8), @@ -675,6 +794,106 @@ class _PatientDetailConsentScreenState ); } + Widget _buildTranscriptCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: const [ + Icon(Icons.record_voice_over_rounded, color: AppColors.primary, size: 20), + SizedBox(width: 8), + Text( + 'Detail Transkrip Audio Percakapan', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.textPrimary), + ), + ], + ), + InkWell( + onTap: () => setState(() => _showTranscript = !_showTranscript), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + Text( + _showTranscript ? 'Sembunyikan' : 'Buka Transkrip', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: AppColors.primary), + ), + Icon( + _showTranscript ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, + color: AppColors.primary, + size: 18, + ), + ], + ), + ), + ), + ], + ), + if (_showTranscript) ...[ + const SizedBox(height: 10), + const Divider(height: 1, color: Color(0xFFF0F2F8)), + const SizedBox(height: 10), + ..._sampleTranscript.map((t) => _buildTranscriptBubble(t)), + ], + ], + ), + ); + } + + Widget _buildTranscriptBubble(Map item) { + final isPatient = item['speaker'] == 'Pasien'; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isPatient ? const Color(0xFFF7F8FC) : AppColors.primaryContainer.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isPatient ? const Color(0xFFECEFF8) : AppColors.primary.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + item['speaker'] ?? '', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 11, + color: isPatient ? AppColors.textPrimary : AppColors.primary, + ), + ), + Text( + item['time'] ?? '', + style: const TextStyle(fontSize: 10, color: AppColors.textLight), + ), + ], + ), + const SizedBox(height: 4), + Text( + item['text'] ?? '', + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4), + ), + ], + ), + ); + } + Widget _buildSoftInput(String label, TextEditingController controller) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -817,7 +1036,13 @@ class _PatientDetailConsentScreenState onPressed: () async { final finished = await Navigator.of(context).pushNamed( '/counselor-video-call', - arguments: schedule.sessionId ?? 'session-live-001', + arguments: { + 'sessionId': schedule.sessionId ?? 'session-live-001', + 'patientName': patient.name, + 'scheduledTime': schedule.timeRange, + 'isLiveNow': schedule.isLiveNow, + 'roomOpensAt': schedule.roomOpensAt, + }, ); if (finished == true && mounted) { _populateLunaTranscriptDraft(patient); @@ -916,69 +1141,38 @@ class _PatientDetailConsentScreenState style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, height: 1.5), ), const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFFE2E8F0)), + CustomButton( + label: 'Masuk / Standby Ruang Video Call', + icon: Icons.videocam_rounded, + onPressed: () async { + final finished = await Navigator.of(context).pushNamed( + '/counselor-video-call', + arguments: { + 'sessionId': schedule.sessionId ?? 'session-live-001', + 'patientName': patient.name, + 'scheduledTime': schedule.timeRange, + 'isLiveNow': schedule.isLiveNow, + 'roomOpensAt': schedule.roomOpensAt, + }, + ); + if (finished == true && mounted) { + _populateLunaTranscriptDraft(patient); + } + }, + ), + const SizedBox(height: 10), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD0D4E5)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.lock_outline_rounded, size: 16, color: Color(0xFF94A3B8)), - const SizedBox(width: 8), - Text( - 'Ruang VC Dibuka Pukul ${schedule.roomOpensAt}', - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Color(0xFF94A3B8)), - ), - ], + icon: const Icon(Icons.auto_awesome, color: AppColors.primary, size: 16), + label: const Text( + 'Simulasikan Draf Transkrip AI Luna', + style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 12), ), - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFFD0D4E5)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(vertical: 10), - ), - icon: const Icon(Icons.play_circle_outline_rounded, color: AppColors.textSecondary, size: 15), - label: const Text( - 'Buka VC (Demo)', - style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.bold, fontSize: 11), - ), - onPressed: () async { - final finished = await Navigator.of(context).pushNamed( - '/counselor-video-call', - arguments: schedule.sessionId ?? 'session-live-001', - ); - if (finished == true && mounted) { - _populateLunaTranscriptDraft(patient); - } - }, - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFFD0D4E5)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(vertical: 10), - ), - icon: const Icon(Icons.auto_awesome, color: AppColors.primary, size: 15), - label: const Text( - 'Draf AI (Demo)', - style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 11), - ), - onPressed: () => _populateLunaTranscriptDraft(patient), - ), - ), - ], + onPressed: () => _populateLunaTranscriptDraft(patient), ), ], ), diff --git a/apps/eva_counselor_mobile/lib/features/profile/domain/entities/eva_certificate_entity.dart b/apps/eva_counselor_mobile/lib/features/profile/domain/entities/eva_certificate_entity.dart new file mode 100644 index 0000000..032d936 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/domain/entities/eva_certificate_entity.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class EvaCertificateEntity { + final String id; + final String title; + final String subtitle; + final String recipientName; + final String milestoneCriteria; + final String certificateNumber; + final String issueDate; + final String categoryLabel; + final String description; + final Color badgeColor; + + const EvaCertificateEntity({ + required this.id, + required this.title, + required this.subtitle, + required this.recipientName, + required this.milestoneCriteria, + required this.certificateNumber, + required this.issueDate, + required this.categoryLabel, + required this.description, + this.badgeColor = const Color(0xFFD97706), + }); +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/screens/counselor_profile_screen.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/screens/counselor_profile_screen.dart new file mode 100644 index 0000000..932e8b5 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/screens/counselor_profile_screen.dart @@ -0,0 +1,485 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../../auth/domain/entities/psychologist_user_entity.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; +import '../../../dashboard/presentation/screens/counselor_main_shell_screen.dart'; +import '../widgets/counselor_bank_account_modal.dart'; +import '../widgets/counselor_certificates_modal.dart'; +import '../widgets/counselor_guidelines_modal.dart'; +import '../widgets/counselor_profile_drive_card.dart'; +import '../widgets/counselor_profile_header_card.dart'; +import '../widgets/counselor_profile_menu_card.dart'; +import '../widgets/counselor_profile_schedule_card.dart'; +import '../widgets/counselor_profile_upgrade_card.dart'; +import '../widgets/counselor_rate_modal.dart'; +import '../widgets/counselor_role_upgrade_modal.dart'; +import '../widgets/counselor_security_modal.dart'; +import '../widgets/counselor_str_credentials_modal.dart'; + +class CounselorProfileScreen extends ConsumerStatefulWidget { + const CounselorProfileScreen({super.key}); + + @override + ConsumerState createState() => _CounselorProfileScreenState(); +} + +class _CounselorProfileScreenState extends ConsumerState { + final List _days = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; + final Set _activeDays = {'Sen', 'Sel', 'Rab', 'Kam', 'Jum'}; + TimeOfDay _startTime = const TimeOfDay(hour: 9, minute: 0); + TimeOfDay _endTime = const TimeOfDay(hour: 17, minute: 0); + String _workingHours = '09:00 - 17:00 WIB'; + bool _isVacationMode = false; + int _sessionDuration = 50; + int _bufferTime = 15; + bool _isBackingUp = false; + String _backupFrequency = 'Harian'; + bool _backupWifiOnly = true; + + String _formatNumber(int n) { + return n.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + ); + } + + void _handleBackupNow(PsychologistUserEntity? user) async { + setState(() => _isBackingUp = true); + await Future.delayed(const Duration(milliseconds: 1400)); + if (!mounted) return; + setState(() => _isBackingUp = false); + + ref.read(authStateProvider.notifier).syncDriveBackup( + email: user?.googleDriveEmail ?? 'sarah.anindya@gmail.com', + frequency: _backupFrequency, + ); + + TopToast.show( + context, + title: 'Pencadangan Berhasil! ☁️', + message: '18.4 MB data rekam SOAP & audio transkrip terenkripsi aman di Google Drive Anda.', + type: ToastType.success, + ); + } + + void _handleRestoreDrive() { + TopToast.show( + context, + title: 'Memeriksa Cadangan Google Drive...', + message: 'Data lokal terverifikasi sinkron dengan cadangan cloud terakhir (Hari ini, 10:15 WIB).', + type: ToastType.info, + ); + } + + Widget _buildRoleSwitcherChip(String label, CounselorRole role, CounselorRole? currentRole) { + final isSelected = currentRole == role; + return GestureDetector( + onTap: () { + ref.read(authStateProvider.notifier).switchRole(role); + TopToast.show( + context, + title: 'Pratinjau Peran: $label', + message: 'Tampilan disesuaikan dengan fitur akun $label.', + type: ToastType.info, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: isSelected ? AppColors.primary : const Color(0xFFD6D9E4)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + ), + ), + ); + } + + Widget _buildSectionHeader(String title, IconData icon) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon(icon, size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: AppColors.textPrimary, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authStateProvider); + final user = authState.value; + final userCerts = CounselorCertificatesModal.getCertificatesForUser(user); + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FE), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Profile Banner Card + CounselorProfileHeaderCard(user: user), + + // 1.B. Upgrade Card khusus Relawan Sebaya + if (user?.isVolunteer == true) ...[ + const SizedBox(height: 12), + CounselorProfileUpgradeCard(ref: ref), + ], + + // Role Switcher Preview + Container( + margin: const EdgeInsets.only(top: 10, bottom: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF1F3FA), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + const Icon(Icons.tune_rounded, size: 13, color: AppColors.textSecondary), + const SizedBox(width: 6), + const Text('Pratinjau Peran:', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: AppColors.textSecondary)), + const SizedBox(width: 8), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildRoleSwitcherChip('Teman Sebaya', CounselorRole.peerVolunteer, user?.role), + const SizedBox(width: 6), + _buildRoleSwitcherChip('Mandiri', CounselorRole.professionalIndependent, user?.role), + const SizedBox(width: 6), + _buildRoleSwitcherChip('Mitra', CounselorRole.professionalPartner, user?.role), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // 2. Jadwal Praktik + _buildSectionHeader('Hari & Jam Praktik Online', Icons.calendar_month_rounded), + CounselorProfileScheduleCard( + days: _days, + activeDays: _activeDays, + startTime: _startTime, + endTime: _endTime, + workingHours: _workingHours, + sessionDuration: _sessionDuration, + bufferTime: _bufferTime, + isVacationMode: _isVacationMode, + onDaysChanged: (days) => setState(() => _activeDays..clear()..addAll(days)), + onHoursChanged: (s, e) { + setState(() { + _startTime = s; + _endTime = e; + _workingHours = '${s.hour.toString().padLeft(2, '0')}:${s.minute.toString().padLeft(2, '0')} - ${e.hour.toString().padLeft(2, '0')}:${e.minute.toString().padLeft(2, '0')} WIB'; + }); + }, + onDurationBufferChanged: (d, b) { + setState(() { + _sessionDuration = d; + _bufferTime = b; + }); + }, + onVacationModeChanged: (val) => setState(() => _isVacationMode = val), + ), + const SizedBox(height: 20), + + // 3. Honorarium & Keuangan + _buildSectionHeader('Honorarium & Keuangan Praktik', Icons.account_balance_wallet_outlined), + if (user?.isVolunteer == true) ...[ + CounselorProfileMenuCard( + icon: Icons.workspace_premium_rounded, + iconColor: AppColors.primary, + iconBg: AppColors.primaryContainer, + title: 'Upgrade ke Psikolog Profesional', + subtitle: 'Daftarkan STR/SIPP untuk membuka dompet koin & rekam SOAP', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Upgrade 🚀', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: () => CounselorRoleUpgradeModal.show(context, ref), + ), + ] else if (user?.isPartner == true) ...[ + CounselorProfileMenuCard( + icon: Icons.apartment_rounded, + iconColor: const Color(0xFF7E22CE), + iconBg: const Color(0xFFF3E8FF), + title: 'Tarif & Keuangan Mitra (Web Portal)', + subtitle: 'Koin sesi masuk ke kas lembaga mitra (${user?.partnerName ?? "RS Mitra"})', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF3E8FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Web Mitra', style: TextStyle(color: Color(0xFF7E22CE), fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: () { + TopToast.show( + context, + title: 'Pengaturan Koin Mitra', + message: 'Sebagai psikolog mitra, tarif koin dikelola oleh administrator mitra via Web Portal.', + type: ToastType.info, + ); + }, + ), + ] else ...[ + CounselorProfileMenuCard( + icon: Icons.monetization_on_rounded, + iconColor: const Color(0xFFC2410C), + iconBg: const Color(0xFFFFF7ED), + title: 'Tarif Koin Konsultasi', + subtitle: '${user?.coinsPerSession ?? 150} Koin per $_sessionDuration menit (≈ Rp ${_formatNumber((user?.coinsPerSession ?? 150) * 1000)})', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFFF7ED), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${user?.coinsPerSession ?? 150} Koin', + style: const TextStyle(color: Color(0xFFC2410C), fontWeight: FontWeight.bold, fontSize: 11), + ), + const SizedBox(width: 4), + const Icon(Icons.edit_outlined, size: 12, color: Color(0xFFC2410C)), + ], + ), + ), + onTap: () => CounselorRateModal.show( + context: context, + user: user, + onSave: (coins) { + ref.read(authStateProvider.notifier).updateSessionSettings( + sessionDurationMinutes: _sessionDuration, + coinsPerSession: coins, + ); + }, + ), + ), + CounselorProfileMenuCard( + icon: Icons.account_balance_outlined, + iconColor: const Color(0xFF2563EB), + iconBg: const Color(0xFFEFF6FF), + title: 'Rekening Bank Pencairan Koin', + subtitle: 'BCA • ****8192 (dr. Sarah Jenkins)', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Terverifikasi ✅', style: TextStyle(color: AppColors.verifiedGreen, fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: () => CounselorBankAccountModal.show(context), + ), + ], + const SizedBox(height: 16), + + // 4. Pencadangan Google Drive + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: _buildSectionHeader('Pencadangan Rekam Medis', Icons.cloud_sync_outlined), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('E2EE 256-bit ✅', style: TextStyle(color: AppColors.verifiedGreen, fontWeight: FontWeight.bold, fontSize: 10)), + ), + ], + ), + const SizedBox(height: 4), + const Text( + 'Data rekam medis SOAP & audio transkrip tersimpan lokal di perangkat Anda (bukan di server EVA). Cadangkan ke Google Drive pribadi Anda untuk keamanan.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4), + ), + const SizedBox(height: 10), + CounselorProfileDriveCard( + user: user, + backupFrequency: _backupFrequency, + backupWifiOnly: _backupWifiOnly, + isBackingUp: _isBackingUp, + onSettingsSaved: (freq, wifi) { + setState(() { + _backupFrequency = freq; + _backupWifiOnly = wifi; + }); + ref.read(authStateProvider.notifier).syncDriveBackup( + email: user?.googleDriveEmail ?? 'sarah.anindya@gmail.com', + frequency: freq, + ); + }, + onBackupNow: () => _handleBackupNow(user), + onRestore: _handleRestoreDrive, + ), + const SizedBox(height: 16), + + // 5. Sertifikasi Eva + _buildSectionHeader('Pencapaian & Sertifikasi Eva', Icons.military_tech_rounded), + CounselorProfileMenuCard( + icon: Icons.workspace_premium_rounded, + iconColor: const Color(0xFFD97706), + iconBg: const Color(0xFFFEF3C7), + title: 'Sertifikat Penghargaan & Pencapaian', + subtitle: '${userCerts.length} Sertifikat Resmi Diraih • Siap Diunduh (PDF)', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${userCerts.length} Sertif 🏆', + style: const TextStyle(color: Color(0xFFB45309), fontWeight: FontWeight.bold, fontSize: 10), + ), + ), + onTap: () => CounselorCertificatesModal.showGallery(context, user), + ), + const SizedBox(height: 16), + + // 6. Legalitas & Keamanan + _buildSectionHeader('Legalitas & Keamanan Praktik', Icons.verified_user_outlined), + if (user?.isVolunteer != true) ...[ + CounselorProfileMenuCard( + icon: Icons.verified_user_outlined, + iconColor: AppColors.verifiedGreen, + iconBg: AppColors.verifiedBg, + title: 'Kredensial SIPP & STR Psikolog', + subtitle: 'STR #4192-PSI-2024 • KTKI & HIMPSI', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Aktif 2029', style: TextStyle(color: AppColors.verifiedGreen, fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: () => CounselorStrCredentialsModal.show(context), + ), + ], + CounselorProfileMenuCard( + icon: Icons.shield_outlined, + iconColor: AppColors.primary, + iconBg: AppColors.secondary, + title: 'Keamanan Rekam Medis & Privasi', + subtitle: 'Biometrik FaceID & Enkripsi AES-256', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: const Text('2FA Aktif', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: () => CounselorSecurityModal.show(context), + ), + if (user?.isVolunteer == true) ...[ + CounselorProfileMenuCard( + icon: Icons.menu_book_rounded, + iconColor: const Color(0xFF0284C7), + iconBg: const Color(0xFFE0F2FE), + title: 'Panduan Etika Konselor Teman Sebaya', + subtitle: 'Kode etik pendampingan non-klinis & protokol krisis', + trailingBadge: const SizedBox.shrink(), + onTap: () => CounselorGuidelinesModal.showPeerCounselor(context), + ), + ] else ...[ + CounselorProfileMenuCard( + icon: Icons.menu_book_outlined, + iconColor: const Color(0xFFD97706), + iconBg: const Color(0xFFFEF3C7), + title: 'Panduan Etik & SOP HIMPSI', + subtitle: 'Kode etik layanan telekonseling & penanganan krisis', + trailingBadge: const SizedBox.shrink(), + onTap: () => CounselorGuidelinesModal.showProfessional(context), + ), + ], + CounselorProfileMenuCard( + icon: Icons.explore_rounded, + iconColor: const Color(0xFF6366F1), + iconBg: const Color(0xFFEEF2FF), + title: 'Tur Panduan Aplikasi Konselor', + subtitle: 'Pelajari alur tiap menu, rekam medis, koin & backup Drive', + trailingBadge: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Buka Tur 🧭', style: TextStyle(color: Color(0xFF4F46E5), fontWeight: FontWeight.bold, fontSize: 10)), + ), + onTap: () => ref.read(triggerCounselorSpotlightTourProvider.notifier).state++, + ), + const SizedBox(height: 24), + + // 7. Logout + GestureDetector( + onTap: () async { + await ref.read(authStateProvider.notifier).logout(); + if (context.mounted) { + Navigator.of(context).pushReplacementNamed('/counselor-login'); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: AppColors.highRiskBg, + borderRadius: BorderRadius.circular(16), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.logout, color: AppColors.error, size: 18), + SizedBox(width: 8), + Text( + 'Keluar dari Akun Konselor', + style: TextStyle(color: AppColors.error, fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + ), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_bank_account_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_bank_account_modal.dart new file mode 100644 index 0000000..32e7e43 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_bank_account_modal.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/top_toast.dart'; + +class CounselorBankAccountModal { + static void show(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Rekening Bank Pencairan', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Rekening terdaftar untuk pencairan otomatis honorarium praktik Anda:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1E3A8A), Color(0xFF2563EB)], + ), + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: const Color(0xFF1E3A8A).withValues(alpha: 0.25), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'BCA (Bank Central Asia)', + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('UTAMA', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)), + ), + ], + ), + const SizedBox(height: 18), + const Text( + '•••• •••• •••• 8192', + style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 2), + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text('dr. Sarah Jenkins, M.Psi', style: TextStyle(color: Colors.white70, fontSize: 12)), + Text('TERVERIFIKASI ✅', style: TextStyle(color: Color(0xFF6EE7B7), fontSize: 10, fontWeight: FontWeight.bold)), + ], + ), + ], + ), + ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + children: const [ + Icon(Icons.schedule_rounded, size: 16, color: AppColors.primary), + SizedBox(width: 8), + Expanded( + child: Text( + 'Pencairan dana otomatis diproses setiap hari Jumat pukul 15:00 WIB ke rekening di atas tanpa potongan biaya transfer.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.3), + ), + ), + ], + ), + ), + const SizedBox(height: 18), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD6D9E4)), + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + icon: const Icon(Icons.edit_note_rounded, size: 18, color: AppColors.primary), + label: const Text('Ubah Rekening Bank', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.primary)), + onPressed: () { + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Verifikasi Rekening', + message: 'Permintaan perubahan rekening bank memerlukan verifikasi KTP & SIPP (1x24 jam).', + type: ToastType.info, + ); + }, + ), + const SizedBox(height: 10), + ], + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_certificates_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_certificates_modal.dart new file mode 100644 index 0000000..874d9dd --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_certificates_modal.dart @@ -0,0 +1,596 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../../auth/domain/entities/psychologist_user_entity.dart'; +import '../../domain/entities/eva_certificate_entity.dart'; + +class CounselorCertificatesModal { + static List getCertificatesForUser(PsychologistUserEntity? user) { + final name = user?.displayName ?? 'dr. Sarah Jenkins, M.Psi'; + final isVol = user?.isVolunteer == true; + + if (isVol) { + return [ + EvaCertificateEntity( + id: 'CERT-VOL-01', + title: 'SERTIFIKAT KELULUSAN PEER COUNSELOR', + subtitle: 'Certified Active Listener & Support Specialist', + recipientName: name, + milestoneCriteria: 'Lulus Uji Etika & Pelatihan Konseling Sebaya', + certificateNumber: 'EVA/CERT/2026/VOL/00094', + issueDate: '15 Oktober 2026', + categoryLabel: 'Relawan Teman Sebaya', + description: + 'Diberikan atas kelulusan program sertifikasi pendampingan emosional, komunikasi empatik non-klinis, dan kepatuhan penuh terhadap etika teman sebaya Eva.', + badgeColor: const Color(0xFF0284C7), + ), + EvaCertificateEntity( + id: 'CERT-VOL-02', + title: 'SERTIFIKAT DEDIKASI TEMAN SEBAYA', + subtitle: 'Community Mental Health Dedication Award', + recipientName: name, + milestoneCriteria: 'Menyelesaikan 10 Sesi Curhat Komunitas', + certificateNumber: 'EVA/CERT/2026/VOL/00148', + issueDate: '01 November 2026', + categoryLabel: 'Relawan Teman Sebaya', + description: + 'Diberikan sebagai penghargaan atas ketulusan mendengarkan aktif dan mendampingi keluhan emosional teman sebaya di ekosistem kesehatan mental Eva.', + badgeColor: const Color(0xFFD97706), + ), + EvaCertificateEntity( + id: 'CERT-VOL-03', + title: 'SERTIFIKAT KONTRIBUTOR EMPATI SEBAYA', + subtitle: 'Excellence in Community Empathy 2026', + recipientName: name, + milestoneCriteria: 'Pendampingan 25 Sesi Tanpa Keluhan & Rating 5.0', + certificateNumber: 'EVA/CERT/2026/VOL/00220', + issueDate: '05 November 2026', + categoryLabel: 'Relawan Teman Sebaya', + description: + 'Diberikan atas kontribusi konsisten menjaga ruang aman bebas penghakiman, memberikan pertolongan pertama psikologis suportif bagi sesama.', + badgeColor: const Color(0xFF16A34A), + ), + ]; + } else { + return [ + EvaCertificateEntity( + id: 'CERT-PRO-01', + title: 'SERTIFIKAT PRAKTIK TELEKONSELING KLINIS', + subtitle: 'Clinical Tele-Psychology Excellence & Competence', + recipientName: name, + milestoneCriteria: 'Penyelesaian 25 Sesi Klinis Terverifikasi', + certificateNumber: 'EVA/CERT/2026/PRO/00341', + issueDate: '20 Oktober 2026', + categoryLabel: user?.isPartner == true ? 'Psikolog Mitra' : 'Psikolog Mandiri', + description: + 'Diberikan atas dedikasi dan kompetensi klinis prima dalam menyelenggarakan telekonsultasi kesehatan mental dengan standar etika tertinggi.', + badgeColor: const Color(0xFF2563EB), + ), + EvaCertificateEntity( + id: 'CERT-PRO-02', + title: 'SERTIFIKAT KEPATUHAN REKAM MEDIS SOAP', + subtitle: 'Certified Clinical Medical Record Standard', + recipientName: name, + milestoneCriteria: 'Kepatuhan Rekam SOAP 100% pada 50 Sesi', + certificateNumber: 'EVA/CERT/2026/PRO/00512', + issueDate: '02 November 2026', + categoryLabel: user?.isPartner == true ? 'Psikolog Mitra' : 'Psikolog Mandiri', + description: + 'Diberikan atas komitmen integritas dokumentasi SOAP klinis (Subjective, Objective, Assessment, Plan) yang terenkripsi aman demi keselamatan pasien.', + badgeColor: const Color(0xFFD97706), + ), + EvaCertificateEntity( + id: 'CERT-PRO-03', + title: 'SERTIFIKAT KONSELOR TELADAN EVA 2026', + subtitle: 'Distinguished Clinical Practitioner 2026', + recipientName: name, + milestoneCriteria: '100+ Sesi Terlaksana dengan Rating Kepuasan 4.9+', + certificateNumber: 'EVA/CERT/2026/PRO/00789', + issueDate: '06 November 2026', + categoryLabel: user?.isPartner == true ? 'Psikolog Mitra' : 'Psikolog Mandiri', + description: + 'Penghargaan tertinggi platform Eva atas loyalitas pelayanan intervensi krisis, kepuasan pasien bintang lima, dan kontribusi nyata pada kesehatan jiwa bangsa.', + badgeColor: const Color(0xFF7E22CE), + ), + ]; + } + } + + static void showGallery(BuildContext context, PsychologistUserEntity? user) { + final certs = getCertificatesForUser(user); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 14, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.workspace_premium_rounded, color: Color(0xFFD97706), size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Sertifikat & Pencapaian', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.textPrimary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '${certs.length} Sertifikat resmi diraih berdasarkan milestone', + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 22, color: AppColors.textSecondary), + splashRadius: 22, + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 14), + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.65, + ), + child: ListView.separated( + shrinkWrap: true, + physics: const BouncingScrollPhysics(), + itemCount: certs.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (modalCtx, idx) { + final c = certs[idx]; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: c.badgeColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.military_tech_rounded, color: c.badgeColor, size: 20), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + c.title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.textPrimary), + ), + const SizedBox(height: 2), + Text( + c.milestoneCriteria, + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + c.issueDate, + style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: Color(0xFF64748B)), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + c.description, + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const Divider(height: 20, color: Color(0xFFF1F5F9)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'No: ${c.certificateNumber}', + style: const TextStyle(fontSize: 10, color: Color(0xFF94A3B8), fontWeight: FontWeight.w500), + ), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + icon: const Icon(Icons.remove_red_eye_outlined, size: 14), + label: const Text('Buka & Unduh', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + onPressed: () { + Navigator.of(modalCtx).pop(); + showOfficialCertificate(context, c); + }, + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ), + ); + } + + static void showOfficialCertificate(BuildContext context, EvaCertificateEntity cert) { + showDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 20), + backgroundColor: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(22), + boxShadow: const [ + BoxShadow(color: Colors.black38, blurRadius: 28, offset: Offset(0, 10)), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(top: 8, right: 8, left: 16), + child: Row( + children: [ + const Icon(Icons.workspace_premium_rounded, size: 18, color: Color(0xFFD97706)), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Pratinjau Sertifikat Resmi', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5, color: AppColors.textPrimary), + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 22, color: AppColors.textSecondary), + splashRadius: 22, + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFFDF8), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFB45309), width: 3), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD97706).withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFD97706), width: 1.2), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text('✦', style: TextStyle(color: Color(0xFFB45309), fontSize: 14)), + Text('✦', style: TextStyle(color: Color(0xFFB45309), fontSize: 14)), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.spa_rounded, color: AppColors.primary, size: 18), + SizedBox(width: 6), + Text( + 'EVA MENTAL HEALTH ECOSYSTEM', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 1.8, + color: Color(0xFF1E3A8A), + ), + ), + ], + ), + const SizedBox(height: 2), + const Text( + 'LEMBAGA LAYANAN TELEKONSELING & KESEHATAN JIWA TERINTEGRASI', + style: TextStyle( + fontSize: 7.5, + letterSpacing: 0.8, + color: Color(0xFF64748B), + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + Text( + cert.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w900, + letterSpacing: 1.2, + color: Color(0xFF1E293B), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 2), + Text( + cert.subtitle, + style: const TextStyle( + fontSize: 9.5, + fontStyle: FontStyle.italic, + color: Color(0xFFB45309), + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container(width: 40, height: 1.2, color: const Color(0xFFD97706)), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('★', style: TextStyle(color: Color(0xFFD97706), fontSize: 10)), + ), + Container(width: 40, height: 1.2, color: const Color(0xFFD97706)), + ], + ), + const SizedBox(height: 12), + const Text( + 'Diberikan dengan penuh hormat & apresiasi kepada:', + style: TextStyle(fontSize: 10, color: Color(0xFF64748B), fontStyle: FontStyle.italic), + ), + const SizedBox(height: 6), + Text( + cert.recipientName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + letterSpacing: 0.5, + ), + textAlign: TextAlign.center, + ), + Container( + margin: const EdgeInsets.only(top: 4, bottom: 8), + height: 1.5, + width: 140, + color: const Color(0xFFD97706), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '★ ${cert.milestoneCriteria} ★', + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Color(0xFF92400E)), + ), + ), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + cert.description, + style: const TextStyle(fontSize: 10.5, color: Color(0xFF334155), height: 1.45), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 12), + Text( + 'No: ${cert.certificateNumber} • Diterbitkan di Jakarta, ${cert.issueDate}', + style: const TextStyle(fontSize: 9, color: Color(0xFF64748B), fontWeight: FontWeight.w600), + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Column( + children: [ + const Text( + 'dr. Robert W., Sp.KJ', + style: TextStyle( + fontFamily: 'serif', + fontSize: 11, + fontWeight: FontWeight.bold, + color: Color(0xFF1E3A8A), + fontStyle: FontStyle.italic, + ), + ), + Container(height: 1, width: 85, color: const Color(0xFFCBD5E1)), + const SizedBox(height: 2), + const Text('Ketua Komite Medis', style: TextStyle(fontSize: 8, color: Color(0xFF64748B))), + ], + ), + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const RadialGradient( + colors: [Color(0xFFFDE68A), Color(0xFFD97706), Color(0xFFB45309)], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD97706).withValues(alpha: 0.35), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + border: Border.all(color: const Color(0xFFFEF3C7), width: 1.5), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.workspace_premium_rounded, size: 20, color: Colors.white), + Text( + 'VERIFIED', + style: TextStyle( + fontSize: 6, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ), + Column( + children: [ + const Text( + 'Nadine L., M.Psi', + style: TextStyle( + fontFamily: 'serif', + fontSize: 11, + fontWeight: FontWeight.bold, + color: Color(0xFF1E3A8A), + fontStyle: FontStyle.italic, + ), + ), + Container(height: 1, width: 85, color: const Color(0xFFCBD5E1)), + const SizedBox(height: 2), + const Text('Direktur Konseling', style: TextStyle(fontSize: 8, color: Color(0xFF64748B))), + ], + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text('✦', style: TextStyle(color: Color(0xFFB45309), fontSize: 14)), + Text('✦', style: TextStyle(color: Color(0xFFB45309), fontSize: 14)), + ], + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + OutlinedButton( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFE2E8F0)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Tutup', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: const Icon(Icons.download_rounded, size: 18), + label: const Text('Unduh Sertifikat (PDF)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + onPressed: () { + Navigator.of(ctx).pop(); + TopToast.show( + context, + title: 'Sertifikat Berhasil Diunduh! 📄', + message: 'Tersimpan di unduhan: ${cert.certificateNumber.replaceAll("/", "_")}.pdf', + type: ToastType.success, + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_drive_backup_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_drive_backup_modal.dart new file mode 100644 index 0000000..9f72afd --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_drive_backup_modal.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/custom_button.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../../auth/domain/entities/psychologist_user_entity.dart'; + +class CounselorDriveBackupModal { + static void show({ + required BuildContext context, + required PsychologistUserEntity? user, + required String initialFrequency, + required bool initialWifiOnly, + required Function(String frequency, bool wifiOnly) onSave, + }) { + String tempFreq = initialFrequency; + bool tempWifi = initialWifiOnly; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Pengaturan Cadangan Google Drive', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Rekam medis terenkripsi dicadangkan ke Google Drive pribadi Anda:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 14), + Row( + children: [ + const Text('Frekuensi: ', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(width: 8), + ...['Harian', 'Mingguan', 'Manual'].map((f) => Padding( + padding: const EdgeInsets.only(right: 6), + child: ChoiceChip( + label: Text(f), + selected: tempFreq == f, + onSelected: (_) => setModalState(() => tempFreq = f), + ), + )), + ], + ), + const SizedBox(height: 12), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Cadangkan Hanya Melalui Wi-Fi', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + subtitle: const Text('Hindari penggunaan kuota seluler untuk pencadangan', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + value: tempWifi, + activeColor: AppColors.primary, + onChanged: (val) => setModalState(() => tempWifi = val), + ), + const SizedBox(height: 16), + CustomButton( + label: 'Simpan Preferensi', + onPressed: () { + onSave(tempFreq, tempWifi); + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Preferensi Cadangan Disimpan', + message: 'Jadwal cadangan otomatis Google Drive diatur ke $tempFreq.', + type: ToastType.success, + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_duration_buffer_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_duration_buffer_modal.dart new file mode 100644 index 0000000..fa6d000 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_duration_buffer_modal.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/top_toast.dart'; + +class CounselorDurationBufferModal { + static Future show({ + required BuildContext context, + required int initialDuration, + required int initialBuffer, + required Function(int duration, int buffer) onSave, + }) async { + int tempDuration = initialDuration; + int tempBuffer = initialBuffer; + + await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Durasi Sesi & Jeda Istirahat', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Tentukan durasi panggilan konsultasi dan waktu istirahat antar pasien secara fleksibel:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Durasi Konseling:', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12)), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove, size: 16, color: AppColors.primary), + visualDensity: VisualDensity.compact, + onPressed: () { + if (tempDuration > 15) { + setModalState(() => tempDuration -= 5); + } + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '$tempDuration Menit', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.primary), + ), + ), + IconButton( + icon: const Icon(Icons.add, size: 16, color: AppColors.primary), + visualDensity: VisualDensity.compact, + onPressed: () { + if (tempDuration < 180) { + setModalState(() => tempDuration += 5); + } + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [30, 45, 50, 60, 75, 90].map((dur) { + final isSelected = tempDuration == dur; + return GestureDetector( + onTap: () => setModalState(() => tempDuration = dur), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFECEFF8), + width: 1.2, + ), + ), + child: Text( + '$dur Mnt${dur == 50 ? " (Standar)" : ""}', + style: TextStyle( + color: isSelected ? Colors.white : AppColors.textPrimary, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, + fontSize: 11, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Jeda Istirahat (Buffer Time):', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12)), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove, size: 16, color: AppColors.primary), + visualDensity: VisualDensity.compact, + onPressed: () { + if (tempBuffer > 0) { + setModalState(() => tempBuffer -= 5); + } + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '$tempBuffer Menit', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.primary), + ), + ), + IconButton( + icon: const Icon(Icons.add, size: 16, color: AppColors.primary), + visualDensity: VisualDensity.compact, + onPressed: () { + if (tempBuffer < 60) { + setModalState(() => tempBuffer += 5); + } + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [0, 5, 10, 15, 20, 30].map((buf) { + final isSelected = tempBuffer == buf; + return GestureDetector( + onTap: () => setModalState(() => tempBuffer = buf), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected ? AppColors.primary : const Color(0xFFECEFF8), + width: 1.2, + ), + ), + child: Text( + buf == 0 ? 'Tanpa Jeda' : '$buf Mnt${buf == 15 ? " (Disarankan)" : ""}', + style: TextStyle( + color: isSelected ? Colors.white : AppColors.textPrimary, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, + fontSize: 11, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.primary.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline_rounded, size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Total slot waktu: ${tempDuration + tempBuffer} menit ($tempDuration mnt telekonseling + $tempBuffer mnt review SOAP).', + style: const TextStyle(fontSize: 11, color: AppColors.textPrimary), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(46), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + onPressed: () { + onSave(tempDuration, tempBuffer); + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Durasi Sesi Diperbarui', + message: 'Sesi ditetapkan ke $tempDuration menit dengan jeda istirahat $tempBuffer menit.', + type: ToastType.success, + ); + }, + child: const Text('Simpan Durasi Sesi', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_guidelines_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_guidelines_modal.dart new file mode 100644 index 0000000..3015168 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_guidelines_modal.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; + +class CounselorGuidelinesModal { + static void showProfessional(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Panduan Etik & SOP HIMPSI', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 8), + const Text( + 'Standar Operasional Prosedur Layanan Telekonseling Luna:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 14), + _ethicItem('1. Kerahasiaan & Batas Etik', 'Data rekam medis SOAP terenkripsi end-to-end dan hanya dapat diakses psikolog penanggung jawab.'), + const SizedBox(height: 8), + _ethicItem('2. Protokol De-eskalasi Krisis', 'Jika skor DASS-21 atau deteksi Luna AI menunjukkan risiko self-harm, psikolog wajib mengaktifkan hotline darurat.'), + const SizedBox(height: 8), + _ethicItem('3. Dokumentasi SOAP Tepat Waktu', 'Psikolog diharapkan mereview dan memverifikasi draft auto-SOAP Luna maksimal 24 jam setelah sesi selesai.'), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + onPressed: () => Navigator.pop(ctx), + child: const Text('Tutup', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 10), + ], + ), + ), + ); + } + + static void showPeerCounselor(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFE0F2FE), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.menu_book_rounded, color: Color(0xFF0284C7), size: 20), + ), + const SizedBox(width: 10), + const Text( + 'Panduan Etika Teman Sebaya', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 8), + const Text( + 'Pedoman Etik Pendampingan Non-Klinis & Batasan Peran Relawan Eva:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 14), + _ethicItem( + '1. Batasan Peran Sebaya (Non-Klinis)', + 'Relawan berperan murni sebagai teman curhat & pendengar aktif yang suportif. Tidak berwenang memberikan diagnosis klinis atau saran medis.', + ), + const SizedBox(height: 8), + _ethicItem( + '2. Tanpa Beban Rekam Medis SOAP', + 'Sesi pendampingan teman sebaya bebas dari kewajiban rekam SOAP klinis. Fokus utama Anda adalah hadir penuh bagi teman curhat.', + ), + const SizedBox(height: 8), + _ethicItem( + '3. Protokol Deteksi & Eskalasi Bahaya', + 'Apabila teman curhat menunjukkan indikasi melukai diri (self-harm) atau krisis berat, wajib segera arahkan atau eskalasi ke Psikolog Profesional.', + ), + const SizedBox(height: 8), + _ethicItem( + '4. Kerahasiaan Penuh & Tanpa Penghakiman', + 'Seluruh cerita dijaga kerahasiaannya dengan penerimaan tulus dan empati tanpa menghakimi latar belakang teman curhat.', + ), + const SizedBox(height: 16), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + onPressed: () => Navigator.pop(ctx), + child: const Text('Saya Mengerti & Sepakat', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 10), + ], + ), + ), + ), + ); + } + + static Widget _ethicItem(String title, String desc) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(desc, style: const TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.3)), + ], + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_drive_card.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_drive_card.dart new file mode 100644 index 0000000..a62bb5a --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_drive_card.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../auth/domain/entities/psychologist_user_entity.dart'; +import 'counselor_drive_backup_modal.dart'; + +class CounselorProfileDriveCard extends StatelessWidget { + final PsychologistUserEntity? user; + final String backupFrequency; + final bool backupWifiOnly; + final bool isBackingUp; + final Function(String freq, bool wifi) onSettingsSaved; + final VoidCallback onBackupNow; + final VoidCallback onRestore; + + const CounselorProfileDriveCard({ + super.key, + required this.user, + required this.backupFrequency, + required this.backupWifiOnly, + required this.isBackingUp, + required this.onSettingsSaved, + required this.onBackupNow, + required this.onRestore, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.cloud_sync_rounded, color: AppColors.verifiedGreen, size: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user?.googleDriveEmail ?? 'sarah.anindya@gmail.com', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + const SizedBox(height: 2), + Text( + 'Cadangan: ${user?.lastDriveBackup != null ? "Hari ini, 10:15 WIB (18.4 MB)" : "Belum dicadangkan"} • Otomatis $backupFrequency', + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.tune_rounded, color: AppColors.primary, size: 20), + onPressed: () => CounselorDriveBackupModal.show( + context: context, + user: user, + initialFrequency: backupFrequency, + initialWifiOnly: backupWifiOnly, + onSave: onSettingsSaved, + ), + ), + ], + ), + const Divider(height: 20, color: Color(0xFFF0F2F8)), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFECEFF8)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), + ), + icon: const Icon(Icons.cloud_download_outlined, size: 16, color: AppColors.textPrimary), + label: const FittedBox( + fit: BoxFit.scaleDown, + child: Text('Pulihkan Data', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ), + onPressed: onRestore, + ), + ), + const SizedBox(width: 10), + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), + ), + icon: isBackingUp + ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Icon(Icons.cloud_upload_rounded, size: 16), + label: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + isBackingUp ? 'Mencadangkan...' : 'Cadangkan Sekarang', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ), + onPressed: isBackingUp ? null : onBackupNow, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_header_card.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_header_card.dart new file mode 100644 index 0000000..8e7ffd4 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_header_card.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/status_badge.dart'; +import '../../../auth/domain/entities/psychologist_user_entity.dart'; +import 'counselor_reviews_modal.dart'; + +class CounselorProfileHeaderCard extends StatelessWidget { + final PsychologistUserEntity? user; + + const CounselorProfileHeaderCard({super.key, required this.user}); + + Widget _statItem(String val, String label) { + return Column( + children: [ + Text(val, style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 14, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(label, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.04), + blurRadius: 18, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + Row( + children: [ + CircleAvatar( + radius: 34, + backgroundColor: AppColors.primaryContainer, + backgroundImage: user?.profilePhotoUrl != null + ? NetworkImage(user!.profilePhotoUrl!) + : null, + child: user?.profilePhotoUrl == null + ? const Icon(Icons.person, size: 34, color: AppColors.primary) + : null, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user?.displayName ?? 'dr. Sarah Jenkins, M.Psi', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 2), + Text( + user?.email ?? 'sarah.jenkins@psikolog.eva.id', + style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (user?.isVolunteer == true) + StatusBadge.volunteer('Relawan Teman Sebaya') + else if (user?.isPartner == true) + StatusBadge.proPartner('Psikolog Mitra') + else + StatusBadge.proIndependent('Psikolog Mandiri'), + if (user?.isProfessional == true) + StatusBadge.coins(user?.coinBalance ?? 1250), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + InkWell( + onTap: () => CounselorReviewsModal.show(context), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: _statItem('⭐ 4.9', '140 Ulasan >'), + ), + ), + Container(width: 1, height: 28, color: const Color(0xFFE2E5F0)), + _statItem('38', 'Sesi Bulan Ini'), + Container(width: 1, height: 28, color: const Color(0xFFE2E5F0)), + _statItem('100%', 'Respon Booking'), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_menu_card.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_menu_card.dart new file mode 100644 index 0000000..a16d516 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_menu_card.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; + +class CounselorProfileMenuCard extends StatelessWidget { + final IconData icon; + final Color iconColor; + final Color iconBg; + final String title; + final String subtitle; + final Widget trailingBadge; + final VoidCallback onTap; + + const CounselorProfileMenuCard({ + super.key, + required this.icon, + required this.iconColor, + required this.iconBg, + required this.title, + required this.subtitle, + required this.trailingBadge, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: iconBg, + borderRadius: BorderRadius.circular(13), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: AppColors.textPrimary)), + const SizedBox(height: 2), + Text(subtitle, style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ), + ), + const SizedBox(width: 8), + trailingBadge, + const SizedBox(width: 4), + const Icon(Icons.chevron_right_rounded, color: AppColors.textLight, size: 20), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_schedule_card.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_schedule_card.dart new file mode 100644 index 0000000..284b264 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_schedule_card.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import 'counselor_duration_buffer_modal.dart'; +import 'counselor_working_hours_modal.dart'; + +class CounselorProfileScheduleCard extends StatefulWidget { + final List days; + final Set activeDays; + final TimeOfDay startTime; + final TimeOfDay endTime; + final String workingHours; + final int sessionDuration; + final int bufferTime; + final bool isVacationMode; + final Function(Set activeDays) onDaysChanged; + final Function(TimeOfDay start, TimeOfDay end) onHoursChanged; + final Function(int duration, int buffer) onDurationBufferChanged; + final ValueChanged onVacationModeChanged; + + const CounselorProfileScheduleCard({ + super.key, + required this.days, + required this.activeDays, + required this.startTime, + required this.endTime, + required this.workingHours, + required this.sessionDuration, + required this.bufferTime, + required this.isVacationMode, + required this.onDaysChanged, + required this.onHoursChanged, + required this.onDurationBufferChanged, + required this.onVacationModeChanged, + }); + + @override + State createState() => _CounselorProfileScheduleCardState(); +} + +class _CounselorProfileScheduleCardState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Hari Praktik Aktif:', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: widget.days.map((day) { + final isActive = widget.activeDays.contains(day); + return GestureDetector( + onTap: () { + final newDays = Set.from(widget.activeDays); + if (isActive) { + if (newDays.length > 1) newDays.remove(day); + } else { + newDays.add(day); + } + widget.onDaysChanged(newDays); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: isActive ? AppColors.primary : const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isActive ? AppColors.primary : const Color(0xFFECEFF8), + width: 1.2, + ), + boxShadow: isActive + ? [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.18), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isActive) ...[ + const Icon(Icons.check_rounded, color: Colors.white, size: 13), + const SizedBox(width: 4), + ], + Text( + day, + style: TextStyle( + color: isActive ? Colors.white : AppColors.textPrimary, + fontWeight: isActive ? FontWeight.bold : FontWeight.w600, + fontSize: 12, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + const Divider(height: 24, color: Color(0xFFF0F2F8)), + InkWell( + onTap: () => CounselorWorkingHoursModal.show( + context: context, + initialStart: widget.startTime, + initialEnd: widget.endTime, + onSave: widget.onHoursChanged, + ), + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.access_time_rounded, size: 16, color: AppColors.primary), + SizedBox(width: 6), + Text('Jam Operasional Praktik', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.workingHours, + style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 11), + ), + ), + const SizedBox(width: 6), + const Icon(Icons.edit_outlined, size: 15, color: AppColors.primary), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: InkWell( + onTap: () => CounselorDurationBufferModal.show( + context: context, + initialDuration: widget.sessionDuration, + initialBuffer: widget.bufferTime, + onSave: widget.onDurationBufferChanged, + ), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Durasi Sesi', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), + const SizedBox(height: 2), + Text('${widget.sessionDuration} Menit', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + ], + ), + const Icon(Icons.tune_rounded, size: 14, color: AppColors.primary), + ], + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: InkWell( + onTap: () => CounselorDurationBufferModal.show( + context: context, + initialDuration: widget.sessionDuration, + initialBuffer: widget.bufferTime, + onSave: widget.onDurationBufferChanged, + ), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Jeda Buffer', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), + const SizedBox(height: 2), + Text('${widget.bufferTime} Menit', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + ], + ), + const Icon(Icons.tune_rounded, size: 14, color: AppColors.primary), + ], + ), + ), + ), + ), + ], + ), + const Divider(height: 24, color: Color(0xFFF0F2F8)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Mode Libur Sementara (Cuti)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + SizedBox(height: 2), + Text('Tutup sementara pemesanan slot jadwal baru', style: TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ), + Switch.adaptive( + value: widget.isVacationMode, + activeColor: AppColors.warning, + onChanged: widget.onVacationModeChanged, + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_upgrade_card.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_upgrade_card.dart new file mode 100644 index 0000000..ee9e6bf --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_profile_upgrade_card.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import 'counselor_role_upgrade_modal.dart'; + +class CounselorProfileUpgradeCard extends StatelessWidget { + final WidgetRef ref; + + const CounselorProfileUpgradeCard({super.key, required this.ref}); + + Widget _benefitPill(String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFD6E4FF)), + ), + child: Text( + label, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.primary), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFD6E4FF)), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.05), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.workspace_premium_rounded, color: Colors.white, size: 22), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tingkatkan Status Praktik', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: AppColors.textPrimary), + ), + SizedBox(height: 2), + Text( + 'Upgrade ke Psikolog Profesional untuk membuka tarif koin honorarium dan rekam klinis SOAP.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.35), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _benefitPill('💰 Tarif Koin'), + _benefitPill('📋 Rekam SOAP Medis'), + _benefitPill('📜 Sertifikasi Praktisi'), + ], + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: const Icon(Icons.arrow_forward_rounded, size: 16), + label: const Text( + 'Ajukan Verifikasi STR & Upgrade Sekarang', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + onPressed: () => CounselorRoleUpgradeModal.show(context, ref), + ), + ), + ], + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_rate_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_rate_modal.dart new file mode 100644 index 0000000..4731e03 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_rate_modal.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/custom_button.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../../auth/domain/entities/psychologist_user_entity.dart'; + +class CounselorRateModal { + static String formatNumber(int n) { + return n.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + ); + } + + static void show({ + required BuildContext context, + required PsychologistUserEntity? user, + required Function(int coins) onSave, + }) { + int currentCoins = user?.coinsPerSession ?? 150; + final coinsList = [50, 100, 150, 200, 250, 300, 500]; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Atur Tarif Koin per Sesi', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Pasien membayar sesi konsultasi dengan Koin. 1 Koin = Rp 1.000.', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: coinsList.map((c) { + final isSelected = currentCoins == c; + return GestureDetector( + onTap: () => setModalState(() => currentCoins = c), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFFC2410C) : const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? const Color(0xFFC2410C) : const Color(0xFFECEFF8), + ), + ), + child: Text( + '$c Koin (≈ Rp ${formatNumber(c * 1000)})', + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 20), + CustomButton( + label: 'Simpan Tarif Koin', + onPressed: () { + onSave(currentCoins); + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Tarif Koin Berhasil Disimpan', + message: 'Tarif sesi konsultasi kini $currentCoins Koin (≈ Rp ${formatNumber(currentCoins * 1000)}).', + type: ToastType.success, + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_reviews_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_reviews_modal.dart new file mode 100644 index 0000000..96c2969 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_reviews_modal.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; + +class CounselorReviewsModal { + static void show(BuildContext context) { + final reviews = [ + { + 'name': 'R**** P****', + 'topic': 'Konseling Kecemasan & Insomnia', + 'rating': 5, + 'date': '2 hari lalu', + 'comment': 'dr. Sarah sangat mendengarkan tanpa menghakimi. Teknik regulasi napas dan CBT yang diajarkan sangat membantu mengendalikan serangan panik saya. Terima kasih banyak!', + 'tag': 'Sangat Empatik', + }, + { + 'name': 'M**** S****', + 'topic': 'Burnout Akademik & Skripsi', + 'rating': 5, + 'date': '5 hari lalu', + 'comment': 'Sesi konsultasi sangat terstruktur dan menenangkan. Saya mendapat sudut pandang baru dan langkah tindak lanjut yang sangat realistis untuk dijalankan.', + 'tag': 'Solutif & Tenang', + }, + { + 'name': 'A**** N****', + 'topic': 'Regulasi Emosi & Hubungan', + 'rating': 5, + 'date': '1 minggu lalu', + 'comment': 'Dokter sangat ramah, hangat, dan memberikan insight mendalam tentang pola komunikasi saya dengan keluarga.', + 'tag': 'Insight Mendalam', + }, + { + 'name': 'D**** K****', + 'topic': 'Stres Kerja & Manajemen Waktu', + 'rating': 4, + 'date': '2 minggu lalu', + 'comment': 'Sangat membantu memetakan prioritas hidup. Telekonsultasi tepat waktu dan rangkuman SOAP Luna sangat memudahkan saya mereview kembali.', + 'tag': 'Tepat Waktu', + }, + ]; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => DraggableScrollableSheet( + initialChildSize: 0.8, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (_, scrollController) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ListView( + controller: scrollController, + children: [ + const SizedBox(height: 12), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E4EB), + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Ulasan & Penilaian Pasien', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Row( + children: [ + Column( + children: const [ + Text('4.9', style: TextStyle(fontSize: 34, fontWeight: FontWeight.w800, color: AppColors.textPrimary)), + Row( + children: [ + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + Icon(Icons.star_rounded, color: AppColors.warning, size: 16), + ], + ), + SizedBox(height: 2), + Text('140 Ulasan', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ], + ), + const SizedBox(width: 24), + Expanded( + child: Column( + children: [ + _ratingBar('5 ⭐', 0.92, '130'), + const SizedBox(height: 4), + _ratingBar('4 ⭐', 0.06, '8'), + const SizedBox(height: 4), + _ratingBar('3 ⭐', 0.02, '2'), + const SizedBox(height: 4), + _ratingBar('2 ⭐', 0.0, '0'), + const SizedBox(height: 4), + _ratingBar('1 ⭐', 0.0, '0'), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _reviewTagPill('❤️ Sangat Empatik (98%)'), + _reviewTagPill('💡 Solutif (95%)'), + _reviewTagPill('⏱️ Tepat Waktu (99%)'), + ], + ), + const SizedBox(height: 18), + const Text('Testimoni Pasien (Anonim demi Privasi):', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 10), + ...reviews.map((r) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + CircleAvatar( + radius: 14, + backgroundColor: AppColors.primaryContainer, + child: const Icon(Icons.person, size: 16, color: AppColors.primary), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(r['name'] as String, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), + Text(r['topic'] as String, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ), + ], + ), + Text(r['date'] as String, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary)), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + ...List.generate( + r['rating'] as int, + (_) => const Icon(Icons.star_rounded, color: AppColors.warning, size: 14), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.secondary, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + r['tag'] as String, + style: const TextStyle(color: AppColors.primary, fontSize: 9, fontWeight: FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + r['comment'] as String, + style: const TextStyle(fontSize: 11, height: 1.4, color: AppColors.textPrimary), + ), + ], + ), + ); + }), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } + + static Widget _ratingBar(String label, double pct, String count) { + return Row( + children: [ + SizedBox(width: 26, child: Text(label, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary))), + const SizedBox(width: 6), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: pct, + backgroundColor: const Color(0xFFE5E9F2), + valueColor: const AlwaysStoppedAnimation(AppColors.warning), + minHeight: 6, + ), + ), + ), + const SizedBox(width: 6), + SizedBox(width: 22, child: Text(count, textAlign: TextAlign.right, style: const TextStyle(fontSize: 10, color: AppColors.textSecondary))), + ], + ); + } + + static Widget _reviewTagPill(String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF0F3FA), + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_role_upgrade_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_role_upgrade_modal.dart new file mode 100644 index 0000000..ddf3837 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_role_upgrade_modal.dart @@ -0,0 +1,378 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/custom_button.dart'; +import '../../../../core/widgets/top_toast.dart'; +import '../../../auth/domain/entities/psychologist_user_entity.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; + +class CounselorRoleUpgradeModal { + static void show(BuildContext context, WidgetRef ref) { + final strCtrl = TextEditingController(); + final assocCtrl = TextEditingController(text: 'HIMPSI'); + final partnerCtrl = TextEditingController(); + CounselorRole targetRole = CounselorRole.professionalIndependent; + String? pickedFile; + bool isUpgrading = false; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) { + final isMandiri = targetRole == CounselorRole.professionalIndependent; + + return Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 16, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE2E8F0), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + child: const Icon(Icons.workspace_premium_rounded, color: AppColors.primary, size: 22), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Upgrade ke Psikolog Profesional', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppColors.textPrimary), + ), + SizedBox(height: 2), + Text( + 'Daftarkan nomor STR/SIPP aktif untuk membuka dompet koin dan rekam SOAP klinis.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.3), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close_rounded, size: 20, color: AppColors.textSecondary), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFF1F3FA), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => setModalState(() => targetRole = CounselorRole.professionalIndependent), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isMandiri ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isMandiri ? const Color(0xFFECEFF8) : Colors.transparent, + ), + boxShadow: isMandiri + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.person_outline_rounded, + size: 15, + color: isMandiri ? AppColors.primary : AppColors.textSecondary, + ), + const SizedBox(width: 6), + Text( + 'Praktik Mandiri', + style: TextStyle( + fontSize: 12, + fontWeight: isMandiri ? FontWeight.bold : FontWeight.w500, + color: isMandiri ? AppColors.primary : AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: GestureDetector( + onTap: () => setModalState(() => targetRole = CounselorRole.professionalPartner), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: !isMandiri ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: !isMandiri ? const Color(0xFFECEFF8) : Colors.transparent, + ), + boxShadow: !isMandiri + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.apartment_rounded, + size: 15, + color: !isMandiri ? const Color(0xFF7E22CE) : AppColors.textSecondary, + ), + const SizedBox(width: 6), + Text( + 'Dengan Mitra', + style: TextStyle( + fontSize: 12, + fontWeight: !isMandiri ? FontWeight.bold : FontWeight.w500, + color: !isMandiri ? const Color(0xFF7E22CE) : AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + if (!isMandiri) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFAF5FF), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE9D5FF)), + ), + child: const Row( + children: [ + Icon(Icons.info_outline_rounded, color: Color(0xFF7E22CE), size: 16), + SizedBox(width: 8), + Expanded( + child: Text( + 'Akun psikolog mitra diatur jadwal dan tarif koinnya via Web Portal Mitra.', + style: TextStyle(fontSize: 11, color: Color(0xFF6B21A8), height: 1.3), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + ], + const Text('Nomor STR / SIPP Aktif', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 6), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: TextField( + controller: strCtrl, + decoration: const InputDecoration( + hintText: 'Misal: 4192-PSI-2024', + hintStyle: TextStyle(fontSize: 13, color: AppColors.textLight), + prefixIcon: Icon(Icons.badge_outlined, size: 18, color: AppColors.primary), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + ), + const SizedBox(height: 12), + const Text('Organisasi Profesi Psikologi', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 6), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: TextField( + controller: assocCtrl, + decoration: const InputDecoration( + hintText: 'HIMPSI / IPK Indonesia', + hintStyle: TextStyle(fontSize: 13, color: AppColors.textLight), + prefixIcon: Icon(Icons.account_balance_outlined, size: 18, color: AppColors.primary), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + ), + if (!isMandiri) ...[ + const SizedBox(height: 12), + const Text('Nama Lembaga / RS Mitra', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 6), + Container( + decoration: BoxDecoration( + color: const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: TextField( + controller: partnerCtrl, + decoration: const InputDecoration( + hintText: 'Misal: RS Jiwa Sanjiwani / PT Sehat Jiwa', + hintStyle: TextStyle(fontSize: 13, color: AppColors.textLight), + prefixIcon: Icon(Icons.apartment_rounded, size: 18, color: Color(0xFF7E22CE)), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 12), + ), + ), + ), + ], + const SizedBox(height: 14), + const Text('Unggah Berkas Bukti STR', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.textPrimary)), + const SizedBox(height: 6), + InkWell( + onTap: () { + setModalState(() { + pickedFile = 'STR_Verified_${DateTime.now().year}.pdf'; + }); + }, + borderRadius: BorderRadius.circular(14), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + decoration: BoxDecoration( + color: pickedFile != null ? AppColors.verifiedBg : const Color(0xFFF7F8FC), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: pickedFile != null ? const Color(0xFFBBF7D0) : const Color(0xFFECEFF8), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: pickedFile != null ? const Color(0xFFDCFCE7) : AppColors.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + pickedFile != null ? Icons.check_circle_rounded : Icons.cloud_upload_outlined, + size: 18, + color: pickedFile != null ? AppColors.verifiedGreen : AppColors.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pickedFile ?? 'Pilih File Scan STR (PDF/JPG)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: pickedFile != null ? AppColors.verifiedGreen : AppColors.primary, + ), + ), + const SizedBox(height: 2), + Text( + pickedFile != null ? 'Berkas siap diverifikasi (1.8 MB)' : 'Maksimal 5 MB • Kerahasiaan terenkripsi', + style: const TextStyle(fontSize: 10, color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + CustomButton( + label: 'Verifikasi & Upgrade Sekarang', + isLoading: isUpgrading, + icon: Icons.check_circle_outline_rounded, + onPressed: () async { + if (strCtrl.text.isEmpty) { + TopToast.show( + context, + title: 'Nomor STR Wajib Diisi', + message: 'Mohon masukkan nomor STR/SIPP Anda.', + type: ToastType.error, + ); + return; + } + + setModalState(() => isUpgrading = true); + ref.read(authStateProvider.notifier).upgradeToProfessional( + strNumber: strCtrl.text, + association: assocCtrl.text, + certificateDocFileName: pickedFile ?? 'STR_Uploaded.pdf', + newRole: targetRole, + partnerName: targetRole == CounselorRole.professionalPartner + ? partnerCtrl.text + : null, + ); + + await Future.delayed(const Duration(milliseconds: 500)); + if (!ctx.mounted) return; + Navigator.of(ctx).pop(); + if (!context.mounted) return; + + TopToast.show( + context, + title: 'Akun Berhasil Di-Upgrade!', + message: + 'Selamat! Akun Anda kini aktif sebagai Psikolog Profesional dengan akses dompet koin.', + type: ToastType.success, + ); + }, + ), + ], + ), + ), + ); + }, + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_security_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_security_modal.dart new file mode 100644 index 0000000..3c7241d --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_security_modal.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/top_toast.dart'; + +class CounselorSecurityModal { + static void show(BuildContext context) { + bool biometric = true; + bool twoFactor = true; + bool maskPatient = true; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Keamanan & Privasi Klinis', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Perlindungan data pasien sesuai standar kerahasiaan medis & HIPAA:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 14), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Kunci Biometrik (Face ID / Sidik Jari)', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + subtitle: const Text('Wajib autentikasi sebelum membuka rekam medis pasien', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + value: biometric, + activeColor: AppColors.primary, + onChanged: (val) => setModalState(() => biometric = val), + ), + const Divider(height: 1, color: Color(0xFFF0F2F8)), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Verifikasi 2 Langkah (2FA)', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + subtitle: const Text('Kode OTP saat login di perangkat baru', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + value: twoFactor, + activeColor: AppColors.primary, + onChanged: (val) => setModalState(() => twoFactor = val), + ), + const Divider(height: 1, color: Color(0xFFF0F2F8)), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Masking Nama Pasien di Notifikasi', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)), + subtitle: const Text('Sembunyikan nama terang pasien pada notifikasi layar kunci', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + value: maskPatient, + activeColor: AppColors.primary, + onChanged: (val) => setModalState(() => maskPatient = val), + ), + const SizedBox(height: 18), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, + ), + onPressed: () { + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Keamanan Diperbarui', + message: 'Pengaturan keamanan rekam medis berhasil disimpan.', + type: ToastType.success, + ); + }, + child: const Text('Simpan Pengaturan', style: TextStyle(fontWeight: FontWeight.bold)), + ), + const SizedBox(height: 10), + ], + ), + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_str_credentials_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_str_credentials_modal.dart new file mode 100644 index 0000000..8c81f40 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_str_credentials_modal.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/top_toast.dart'; + +class CounselorStrCredentialsModal { + static void show(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Kredensial SIPP & STR Psikolog', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Kredensial klinis resmi Anda yang terverifikasi di Kementerian Kesehatan & HIMPSI:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.verifiedBg, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.verifiedGreen.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text('Surat Tanda Registrasi (STR)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.verifiedGreen)), + Icon(Icons.verified_rounded, color: AppColors.verifiedGreen, size: 18), + ], + ), + const SizedBox(height: 6), + const Text('Nomor: 4192-PSI-2024', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 2), + const Text('Penerbit: KTKI (Konsil Tenaga Kesehatan Indonesia)', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + const SizedBox(height: 2), + const Text('Masa Berlaku: 18 Oktober 2029 (Aktif)', style: TextStyle(fontSize: 11, color: AppColors.verifiedGreen, fontWeight: FontWeight.w600)), + ], + ), + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Surat Izin Praktik Psikologi (SIPP)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: AppColors.primary)), + SizedBox(height: 6), + Text('Nomor: 503/SIPP/DKS/2023', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + SizedBox(height: 2), + Text('Wilayah Layanan: Nasional (Telekonsultasi)', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + SizedBox(height: 2), + Text('Keanggotaan HIMPSI: Aktif (#HM-9214)', style: TextStyle(fontSize: 11, color: AppColors.textPrimary, fontWeight: FontWeight.w600)), + ], + ), + ), + const SizedBox(height: 18), + OutlinedButton( + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFD6D9E4)), + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Perpanjangan Kredensial', + message: 'Upload pembaruan STR/SIPP melalui portal web admin psikolog.', + type: ToastType.info, + ); + }, + child: const Text('Perbarui Dokumen Kredensial', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.primary)), + ), + const SizedBox(height: 10), + ], + ), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_working_hours_modal.dart b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_working_hours_modal.dart new file mode 100644 index 0000000..5d2b7d3 --- /dev/null +++ b/apps/eva_counselor_mobile/lib/features/profile/presentation/widgets/counselor_working_hours_modal.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/widgets/top_toast.dart'; + +class CounselorWorkingHoursModal { + static String formatTimeOfDay(TimeOfDay tod) { + final h = tod.hour.toString().padLeft(2, '0'); + final m = tod.minute.toString().padLeft(2, '0'); + return '$h:$m'; + } + + static Future show({ + required BuildContext context, + required TimeOfDay initialStart, + required TimeOfDay initialEnd, + required Function(TimeOfDay start, TimeOfDay end) onSave, + }) async { + TimeOfDay tempStart = initialStart; + TimeOfDay tempEnd = initialEnd; + + await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(ctx).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Ubah Jam Operasional Praktik', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + IconButton(icon: const Icon(Icons.close, size: 20), onPressed: () => Navigator.pop(ctx)), + ], + ), + const SizedBox(height: 6), + const Text( + 'Pilih jam mulai dan jam selesai praktik secara manual dengan tombol di bawah:', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 18), + Row( + children: [ + Expanded( + child: InkWell( + onTap: () async { + final picked = await showTimePicker( + context: ctx, + initialTime: tempStart, + builder: (context, child) => Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + ), + child: child!, + ), + ); + if (picked != null) { + setModalState(() => tempStart = picked); + } + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Jam Mulai Praktik', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.access_time_rounded, size: 16, color: AppColors.primary), + const SizedBox(width: 6), + Text( + '${formatTimeOfDay(tempStart)} WIB', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ], + ), + ], + ), + ), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.arrow_forward_rounded, color: AppColors.textLight, size: 18), + ), + Expanded( + child: InkWell( + onTap: () async { + final picked = await showTimePicker( + context: ctx, + initialTime: tempEnd, + builder: (context, child) => Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + ), + child: child!, + ), + ); + if (picked != null) { + setModalState(() => tempEnd = picked); + } + }, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFFF7F8FD), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFECEFF8)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Jam Selesai Praktik', style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + const SizedBox(height: 6), + Row( + children: [ + const Icon(Icons.access_time_filled_rounded, size: 16, color: AppColors.primary), + const SizedBox(width: 6), + Text( + '${formatTimeOfDay(tempEnd)} WIB', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + const Text('Atau Pilih Preset Cepat:', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _presetChip('09:00 - 17:00 (Standar)', () { + setModalState(() { + tempStart = const TimeOfDay(hour: 9, minute: 0); + tempEnd = const TimeOfDay(hour: 17, minute: 0); + }); + }), + _presetChip('08:00 - 16:00 (Pagi)', () { + setModalState(() { + tempStart = const TimeOfDay(hour: 8, minute: 0); + tempEnd = const TimeOfDay(hour: 16, minute: 0); + }); + }), + _presetChip('13:00 - 21:00 (Sore)', () { + setModalState(() { + tempStart = const TimeOfDay(hour: 13, minute: 0); + tempEnd = const TimeOfDay(hour: 21, minute: 0); + }); + }), + _presetChip('18:00 - 22:00 (Malam)', () { + setModalState(() { + tempStart = const TimeOfDay(hour: 18, minute: 0); + tempEnd = const TimeOfDay(hour: 22, minute: 0); + }); + }), + ], + ), + const SizedBox(height: 20), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(46), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + elevation: 0, + ), + onPressed: () { + onSave(tempStart, tempEnd); + Navigator.pop(ctx); + TopToast.show( + context, + title: 'Jam Praktik Diperbarui', + message: 'Jam operasional berhasil disetel ke ${formatTimeOfDay(tempStart)} - ${formatTimeOfDay(tempEnd)} WIB', + type: ToastType.success, + ); + }, + child: const Text('Simpan Jam Operasional', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ); + } + + static Widget _presetChip(String label, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFF0F3FA), + borderRadius: BorderRadius.circular(8), + ), + child: Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ), + ); + } +} diff --git a/apps/eva_counselor_mobile/lib/features/video_call/presentation/screens/video_consultation_screen.dart b/apps/eva_counselor_mobile/lib/features/video_call/presentation/screens/video_consultation_screen.dart index 17a7eec..332378d 100644 --- a/apps/eva_counselor_mobile/lib/features/video_call/presentation/screens/video_consultation_screen.dart +++ b/apps/eva_counselor_mobile/lib/features/video_call/presentation/screens/video_consultation_screen.dart @@ -9,8 +9,17 @@ import '../providers/video_call_provider.dart'; class VideoConsultationScreen extends ConsumerStatefulWidget { final String sessionId; + final String? patientName; + final String? scheduledTime; + final bool isEarlyEntry; - const VideoConsultationScreen({super.key, required this.sessionId}); + const VideoConsultationScreen({ + super.key, + required this.sessionId, + this.patientName, + this.scheduledTime, + this.isEarlyEntry = false, + }); @override ConsumerState createState() => @@ -28,19 +37,22 @@ class _VideoConsultationScreenState @override void initState() { super.initState(); + final name = widget.patientName ?? 'Rian Pratama'; _initialSession = VideoCallSessionEntity( sessionId: widget.sessionId, patientId: 'patient-001', - patientName: 'Rian Pratama', + patientName: name, scheduledTime: DateTime.now(), ); - // Otomatis simulasi pasien masuk setelah 3.5 detik jika tidak diklik manual - _simulatedJoinTimer = Timer(const Duration(milliseconds: 3500), () { - if (mounted && !_isPatientPresent) { - _connectPatient(); - } - }); + // Otomatis simulasi pasien masuk setelah 3.5 detik HANYA jika bukan early entry + if (!widget.isEarlyEntry) { + _simulatedJoinTimer = Timer(const Duration(milliseconds: 3500), () { + if (mounted && !_isPatientPresent) { + _connectPatient(); + } + }); + } } void _connectPatient() { @@ -50,7 +62,7 @@ class _VideoConsultationScreenState TopToast.show( context, title: 'Pasien Telah Bergabung', - message: 'Rian Pratama telah terhubung. Penghitung waktu sesi 50 menit resmi dimulai.', + message: '${_initialSession.patientName} telah terhubung. Penghitung waktu sesi 50 menit resmi dimulai.', type: ToastType.success, ); } @@ -275,13 +287,41 @@ class _VideoConsultationScreenState ), const SizedBox(height: 8), - // TIMER BADGE (MENUNGGU vs AKTIF) + // JADWAL VC CHIP + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white.withValues(alpha: 0.15)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.event_available_rounded, size: 12, color: Colors.white70), + const SizedBox(width: 5), + Text( + 'Jadwal Sesi: ${widget.scheduledTime ?? "14:00 - 14:50 WIB"}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + const SizedBox(height: 6), + + // TIMER / STATUS BADGE (STANDBY vs MENUNGGU vs AKTIF) Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: _isPatientPresent ? AppColors.verifiedGreen.withValues(alpha: 0.85) - : AppColors.pendingAmber.withValues(alpha: 0.85), + : (widget.isEarlyEntry + ? const Color(0xFF2563EB).withValues(alpha: 0.85) + : AppColors.pendingAmber.withValues(alpha: 0.85)), borderRadius: BorderRadius.circular(12), ), child: Row( @@ -290,7 +330,7 @@ class _VideoConsultationScreenState Container( width: 8, height: 8, - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), @@ -299,7 +339,9 @@ class _VideoConsultationScreenState Text( _isPatientPresent ? 'Sesi Aktif: ${_formatDuration(session.callDuration)} / 50:00' - : 'Menunggu Pasien Masuk...', + : (widget.isEarlyEntry + ? 'Standby Lebih Awal • Menunggu Jam Jadwal' + : 'Menunggu Pasien Masuk...'), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, @@ -391,66 +433,180 @@ class _VideoConsultationScreenState ); } - // WAITING STATE WIDGET (BELUM HADIR) + // WAITING / STANDBY STATE WIDGET (BELUM HADIR) Widget _buildWaitingPatientView(VideoCallSessionEntity session) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Stack( - alignment: Alignment.center, + final isEarly = widget.isEarlyEntry; + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ + const SizedBox(height: 70), + Stack( + alignment: Alignment.center, + children: [ + Container( + width: 110, + height: 110, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: (isEarly ? const Color(0xFF2563EB) : AppColors.primary).withValues(alpha: 0.15), + ), + ), + Container( + width: 86, + height: 86, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: isEarly ? const Color(0xFF60A5FA) : AppColors.pendingAmber, + width: 2, + ), + color: const Color(0xFF1E2333), + ), + child: Center( + child: isEarly + ? const Icon(Icons.hourglass_top_rounded, size: 36, color: Color(0xFF93C5FD)) + : Text( + session.patientName.substring(0, 1), + style: const TextStyle(fontSize: 34, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + isEarly + ? 'Ruang Standby Pra-Konseling' + : 'Menunggu ${session.patientName} Hadir...', + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + // Info Jadwal Pill Container( - width: 120, - height: 120, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.primary.withValues(alpha: 0.15), + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withValues(alpha: 0.15)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.access_time_rounded, size: 14, color: Color(0xFF93C5FD)), + const SizedBox(width: 6), + Text( + 'Jadwal Sesi: ${widget.scheduledTime ?? "14:00 - 14:50 WIB"}', + style: const TextStyle( + color: Color(0xFF93C5FD), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], ), ), + const SizedBox(height: 14), + + // Card Penjelasan & Cek Perangkat Container( - width: 90, - height: 90, + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.pendingAmber, width: 2), - color: AppColors.primary.withValues(alpha: 0.3), + color: const Color(0xFF1A1F2C), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), ), - child: Center( - child: Text( - session.patientName.substring(0, 1), - style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.white), - ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, size: 16, color: Color(0xFF93C5FD)), + const SizedBox(width: 8), + Expanded( + child: Text( + isEarly + ? 'Anda masuk lebih awal dari jam jadwal. Ruang sesi sudah aktif & terenkripsi. Durasi 50 menit baru akan berjalan saat pasien resmi masuk pada jam jadwal.' + : 'Ruang telekonsultasi aktif. Penghitung durasi 50 menit akan berjalan otomatis saat pasien masuk ke dalam panggilan.', + style: const TextStyle(color: Colors.white70, fontSize: 11, height: 1.45), + ), + ), + ], + ), + const Divider(height: 20, color: Colors.white12), + + // Status Audio & Kamera Psikolog + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Row( + children: [ + Icon( + session.isMicMuted ? Icons.mic_off : Icons.mic, + size: 14, + color: session.isMicMuted ? AppColors.error : const Color(0xFF4ADE80), + ), + const SizedBox(width: 5), + Text( + session.isMicMuted ? 'Mic Mati' : 'Mic Aktif', + style: const TextStyle(color: Colors.white70, fontSize: 11), + ), + ], + ), + Container(width: 1, height: 14, color: Colors.white24), + Row( + children: [ + Icon( + session.isVideoDisabled ? Icons.videocam_off : Icons.videocam, + size: 14, + color: session.isVideoDisabled ? AppColors.error : const Color(0xFF4ADE80), + ), + const SizedBox(width: 5), + Text( + session.isVideoDisabled ? 'Kamera Mati' : 'Kamera Aktif', + style: const TextStyle(color: Colors.white70, fontSize: 11), + ), + ], + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + + // Tombol Simulasi Masuk Pasien + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + icon: const Icon(Icons.person_add_rounded, size: 16), + label: Text( + isEarly ? 'Simulasikan Pasien Hadir Lebih Awal' : 'Simulasikan Pasien Masuk', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), ), + onPressed: _connectPatient, ), + const SizedBox(height: 10), + + // Tombol Keluar Standby + TextButton.icon( + icon: const Icon(Icons.arrow_back, size: 14, color: Colors.white60), + label: const Text('Keluar & Kembali Nanti', style: TextStyle(color: Colors.white60, fontSize: 12)), + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(height: 80), ], ), - const SizedBox(height: 16), - Text( - 'Menunggu ${session.patientName} Hadir...', - style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 6), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 40), - child: Text( - 'Ruang telekonsultasi aktif. Penghitung durasi 50 menit baru akan berjalan saat pasien resmi masuk ke dalam panggilan.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white60, fontSize: 12, height: 1.4), - ), - ), - const SizedBox(height: 20), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - ), - icon: const Icon(Icons.person_add_rounded, size: 16), - label: const Text('Simulasikan Pasien Masuk', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12)), - onPressed: _connectPatient, - ), - ], + ), ); } diff --git a/apps/eva_counselor_mobile/lib/main.dart b/apps/eva_counselor_mobile/lib/main.dart index c29807c..f4740b6 100644 --- a/apps/eva_counselor_mobile/lib/main.dart +++ b/apps/eva_counselor_mobile/lib/main.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'core/routes/app_router.dart'; import 'core/theme/app_theme.dart'; @@ -8,9 +7,6 @@ import 'core/widgets/web_phone_frame_wrapper.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); - GoogleFonts.pendingFonts([ - GoogleFonts.inter(), - ]); runApp(const ProviderScope(child: EvaCounselorApp())); } diff --git a/apps/eva_counselor_mobile/test/features/auth/counselor_role_and_features_test.dart b/apps/eva_counselor_mobile/test/features/auth/counselor_role_and_features_test.dart new file mode 100644 index 0000000..99564af --- /dev/null +++ b/apps/eva_counselor_mobile/test/features/auth/counselor_role_and_features_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:eva_counselor_mobile/features/auth/domain/entities/psychologist_user_entity.dart'; +import 'package:eva_counselor_mobile/features/auth/data/datasources/mock_auth_datasource.dart'; +import 'package:eva_counselor_mobile/features/auth/data/models/psychologist_auth_dto.dart'; + +void main() { + group('Counselor Role & Multi-Category System Tests', () { + test('Peer volunteer entity attributes and restrictions', () { + const volunteer = PsychologistUserEntity( + id: 'vol-001', + fullName: 'Budi Santoso', + titleDegree: 'Relawan Sebaya', + email: 'budi@volunteer.id', + strNumber: 'VOLUNTEER-PEER', + association: 'Komunitas Peduli Mahasiswa', + specializations: ['Curhat Sebaya', 'Manajemen Stres Akademik'], + yearsOfExperience: 1, + consultationRatePerHour: 0, + verificationStatus: VerificationStatus.verified, + role: CounselorRole.peerVolunteer, + coinsPerSession: 0, + coinBalance: 0, + ); + + expect(volunteer.isVolunteer, true); + expect(volunteer.isProfessional, false); + expect(volunteer.isPartner, false); + expect(volunteer.isIndependent, false); + expect(volunteer.roleBadgeLabel, 'Relawan Teman Sebaya'); + expect(volunteer.displayName, 'Budi Santoso'); + }); + + test('Independent professional entity attributes and coins system', () { + const pro = PsychologistUserEntity( + id: 'pro-001', + fullName: 'Sarah Anindya', + titleDegree: 'M.Psi., Psikolog', + email: 'sarah@psikolog.eva.id', + strNumber: 'STR-PK-198827364-2026', + association: 'HIMPSI', + specializations: ['Kecemasan Dewasa', 'Burnout'], + yearsOfExperience: 5, + consultationRatePerHour: 150000, + verificationStatus: VerificationStatus.verified, + role: CounselorRole.professionalIndependent, + sessionDurationMinutes: 50, + coinsPerSession: 150, + coinBalance: 1250, + googleDriveEmail: 'sarah.anindya@gmail.com', + ); + + expect(pro.isVolunteer, false); + expect(pro.isProfessional, true); + expect(pro.isIndependent, true); + expect(pro.isPartner, false); + expect(pro.roleBadgeLabel, 'Psikolog Profesional (Mandiri)'); + expect(pro.displayName, 'Sarah Anindya, M.Psi., Psikolog'); + expect(pro.coinsPerSession, 150); + expect(pro.coinBalance, 1250); + }); + + test('Partner professional entity attributes and partner affiliation', () { + const partnerPro = PsychologistUserEntity( + id: 'partner-001', + fullName: 'dr. Dimas Wahyu', + titleDegree: 'Sp.KJ', + email: 'dimas@rsmitra.co.id', + strNumber: 'STR-MED-8891-2026', + association: 'IDI / PDSKJI', + specializations: ['Depresi Klinis', 'Trauma Akut'], + yearsOfExperience: 8, + consultationRatePerHour: 250000, + verificationStatus: VerificationStatus.verified, + role: CounselorRole.professionalPartner, + partnerName: 'RS Mitra Sehati Jakarta', + ); + + expect(partnerPro.isPartner, true); + expect(partnerPro.isProfessional, true); + expect(partnerPro.isVolunteer, false); + expect(partnerPro.roleBadgeLabel, 'Psikolog Mitra • RS Mitra Sehati Jakarta'); + }); + + test('MockAuthDataSource registers volunteer without STR and with verified status', () async { + final ds = MockAuthDataSource(); + final response = await ds.register( + const PsychologistRegisterRequestDto( + fullName: 'Anisa Rahma', + titleDegree: 'Relawan Sebaya', + email: 'anisa@volunteer.org', + password: 'password123', + strNumber: 'VOLUNTEER-PEER', + association: 'Komunitas Teman Curhat', + specializations: ['Curhat Keseharian'], + yearsOfExperience: 1, + consultationRate: 0, + certificateDocFileName: 'none', + counselorRole: 'peer_volunteer', + coinsPerSession: 0, + ), + ); + + final entity = response.toEntity(); + expect(entity.role, CounselorRole.peerVolunteer); + expect(entity.verificationStatus, VerificationStatus.verified); + expect(entity.coinsPerSession, 0); + }); + + test('MockAuthDataSource registers partner counselor with partnerName', () async { + final ds = MockAuthDataSource(); + final response = await ds.register( + const PsychologistRegisterRequestDto( + fullName: 'Bambang Kusuma', + titleDegree: 'M.Psi., Psikolog', + email: 'bambang@kliniksehati.id', + password: 'password123', + strNumber: 'STR-PK-991283', + association: 'HIMPSI', + specializations: ['Hubungan & Keluarga'], + yearsOfExperience: 6, + consultationRate: 200000, + certificateDocFileName: 'STR_Bambang.pdf', + counselorRole: 'professional_partner', + partnerName: 'Klinik Sehati Mitra', + coinsPerSession: 200, + ), + ); + + final entity = response.toEntity(); + expect(entity.role, CounselorRole.professionalPartner); + expect(entity.partnerName, 'Klinik Sehati Mitra'); + }); + }); +} diff --git a/apps/eva_counselor_mobile/test/features/auth/partner_activation_test.dart b/apps/eva_counselor_mobile/test/features/auth/partner_activation_test.dart new file mode 100644 index 0000000..3595e97 --- /dev/null +++ b/apps/eva_counselor_mobile/test/features/auth/partner_activation_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:eva_counselor_mobile/features/auth/data/datasources/mock_auth_datasource.dart'; +import 'package:eva_counselor_mobile/features/auth/data/models/psychologist_auth_dto.dart'; +import 'package:eva_counselor_mobile/features/auth/data/repositories/auth_repository_impl.dart'; +import 'package:eva_counselor_mobile/features/auth/domain/entities/psychologist_user_entity.dart'; +import 'package:eva_counselor_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:eva_counselor_mobile/features/auth/presentation/screens/partner_counselor_activation_screen.dart'; + +void main() { + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + }); + + group('Mitra Invitation & Activation Flow Tests', () { + test('MockAuthDataSource registers partner counselor with verified status and persists login', () async { + final dataSource = MockAuthDataSource(); + + const registerDto = PsychologistRegisterRequestDto( + fullName: 'Dr. Sarah Lestari, M.Psi., Psikolog', + titleDegree: 'M.Psi., Psikolog Klinis', + email: 'sarah.lestari@gmail.com', + password: 'securePassword123', + strNumber: 'STR-MITRA-2026-99', + association: 'HIMPSI', + specializations: ['Kecemasan Dewasa'], + yearsOfExperience: 5, + consultationRate: 180000, + certificateDocFileName: 'STR_Mitra_Verified.pdf', + counselorRole: 'professional_partner', + partnerName: 'RS Mitra Sehati Jakarta', + sessionDurationMinutes: 50, + coinsPerSession: 180, + ); + + final registered = await dataSource.register(registerDto); + expect(registered.verificationStatus, equals('verified')); + expect(registered.counselorRole, equals('professional_partner')); + expect(registered.partnerName, equals('RS Mitra Sehati Jakarta')); + + // Subsequent login using newly set password + const loginDto = PsychologistLoginRequestDto( + email: 'sarah.lestari@gmail.com', + password: 'securePassword123', + ); + final loggedIn = await dataSource.login(loginDto); + expect(loggedIn.email, equals('sarah.lestari@gmail.com')); + expect(loggedIn.counselorRole, equals('professional_partner')); + expect(loggedIn.partnerName, equals('RS Mitra Sehati Jakarta')); + expect(loggedIn.verificationStatus, equals('verified')); + }); + + test('AuthStateNotifier activatePartnerAccount activates partner state correctly', () async { + final dataSource = MockAuthDataSource(); + final repository = AuthRepositoryImpl(dataSource: dataSource); + final notifier = AuthStateNotifier(repository); + + await notifier.activatePartnerAccount( + email: 'dimas.mitra@klinik.co.id', + password: 'myNewPassword123', + partnerName: 'Klinik Jiwa Sejahtera', + fullName: 'Dimas Pratama, M.Psi.', + ); + + final state = notifier.state.value; + expect(state, isNotNull); + expect(state!.role, equals(CounselorRole.professionalPartner)); + expect(state.partnerName, equals('Klinik Jiwa Sejahtera')); + expect(state.fullName, equals('Dimas Pratama, M.Psi.')); + expect(state.email, equals('dimas.mitra@klinik.co.id')); + expect(state.isPartner, isTrue); + expect(state.isVolunteer, isFalse); + }); + + testWidgets('PartnerCounselorActivationScreen renders prefilled partner info and password form', (tester) async { + await tester.pumpWidget( + const ProviderScope( + child: MaterialApp( + home: PartnerCounselorActivationScreen( + prefilledEmail: 'sarah.lestari@gmail.com', + partnerName: 'RS Mitra Sehati Jakarta', + fullName: 'Dr. Sarah Lestari, M.Psi., Psikolog', + invitationToken: 'test-token-123', + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Aktivasi Akun Mitra'), findsOneWidget); + expect(find.text('RS Mitra Sehati Jakarta'), findsOneWidget); + expect(find.text('Dr. Sarah Lestari, M.Psi., Psikolog'), findsOneWidget); + expect(find.text('sarah.lestari@gmail.com'), findsOneWidget); + expect(find.text('Kata Sandi Baru'), findsOneWidget); + expect(find.text('Simpan Kata Sandi & Masuk ke EVA'), findsOneWidget); + }); + }); +} diff --git a/apps/eva_counselor_mobile/test/features/dashboard/counselor_tour_guide_and_certificate_test.dart b/apps/eva_counselor_mobile/test/features/dashboard/counselor_tour_guide_and_certificate_test.dart new file mode 100644 index 0000000..281b11e --- /dev/null +++ b/apps/eva_counselor_mobile/test/features/dashboard/counselor_tour_guide_and_certificate_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:eva_counselor_mobile/features/dashboard/presentation/widgets/counselor_spotlight_tour.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('Counselor Spotlight Tour & Navigation Tests', () { + test('hasSeenTour returns false initially and true after markTourSeen', () async { + expect(await CounselorSpotlightTourOverlay.hasSeenTour(), isFalse); + await CounselorSpotlightTourOverlay.markTourSeen(); + expect(await CounselorSpotlightTourOverlay.hasSeenTour(), isTrue); + }); + + testWidgets('Spotlight tour highlights menu with darkened blur backdrop and advances on tap', (tester) async { + int step = 0; + bool isClosed = false; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + if (isClosed) { + return const Scaffold(body: Center(child: Text('Tour Finished'))); + } + return Scaffold( + body: CounselorSpotlightTourOverlay( + currentStep: step, + targetRect: const Rect.fromLTWH(60, 500, 60, 56), + onNext: () { + if (step < CounselorSpotlightTourOverlay.steps.length - 1) { + setState(() => step++); + } else { + setState(() => isClosed = true); + } + }, + onSkip: () { + setState(() => isClosed = true); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Spotlight overlay should be displayed on step 0 + expect(find.byType(CounselorSpotlightTourOverlay), findsOneWidget); + expect(find.text('Beranda Praktik Konseling'), findsOneWidget); + expect(find.text('Menu 1 dari 4'), findsOneWidget); + + // Tap "Lanjut →" to step 2 (Rekam Medis & SOAP) + await tester.tap(find.text('Lanjut →')); + await tester.pumpAndSettle(); + + expect(find.text('Rekam Medis & SOAP'), findsOneWidget); + expect(find.text('Menu 2 dari 4'), findsOneWidget); + + // Tap "Lanjut →" to step 3 (Keuangan & Saldo Koin) + await tester.tap(find.text('Lanjut →')); + await tester.pumpAndSettle(); + + expect(find.text('Keuangan & Saldo Koin'), findsOneWidget); + expect(find.text('Menu 3 dari 4'), findsOneWidget); + + // Tap "Lanjut →" to step 4 (Profil, Drive E2EE & Sertifikat) + await tester.tap(find.text('Lanjut →')); + await tester.pumpAndSettle(); + + expect(find.text('Profil, Drive E2EE & Sertifikat'), findsOneWidget); + expect(find.text('Menu 4 dari 4'), findsOneWidget); + expect(find.text('Mulai Praktik 🚀'), findsOneWidget); + + // Tap "Mulai Praktik 🚀" + await tester.tap(find.text('Mulai Praktik 🚀')); + await tester.pumpAndSettle(); + + // Spotlight overlay is dismissed + expect(find.byType(CounselorSpotlightTourOverlay), findsNothing); + expect(find.text('Tour Finished'), findsOneWidget); + }); + + testWidgets('Spotlight tour can be skipped immediately with Lewati button', (tester) async { + bool isClosed = false; + + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) { + if (isClosed) { + return const Scaffold(body: Center(child: Text('Tour Skipped'))); + } + return Scaffold( + body: CounselorSpotlightTourOverlay( + currentStep: 0, + targetRect: const Rect.fromLTWH(60, 500, 60, 56), + onNext: () {}, + onSkip: () { + setState(() => isClosed = true); + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byType(CounselorSpotlightTourOverlay), findsOneWidget); + + // Tap Lewati + await tester.tap(find.text('Lewati')); + await tester.pumpAndSettle(); + + expect(find.byType(CounselorSpotlightTourOverlay), findsNothing); + expect(find.text('Tour Skipped'), findsOneWidget); + }); + }); +}