Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion apps/eva_counselor_mobile/lib/core/routes/app_router.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,14 +12,33 @@ import 'app_route_transitions.dart';

class AppRouter {
static Route<dynamic>? onGenerateRoute(RouteSettings settings) {
switch (settings.name) {
final uri = Uri.parse(settings.name ?? '/');
switch (uri.path) {
case '/':
case '/counselor-login':
return FadeThroughPageRoute(
page: const PsychologistLoginScreen(),
settings: settings,
);

case '/counselor-activation':
final args = settings.arguments as Map<String, dynamic>?;
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(),
Expand Down Expand Up @@ -51,6 +71,22 @@ class AppRouter {
);

case '/counselor-video-call':
if (settings.arguments is Map<String, dynamic>) {
final args = settings.arguments as Map<String, dynamic>;
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),
Expand Down
4 changes: 2 additions & 2 deletions apps/eva_counselor_mobile/lib/core/widgets/custom_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
Expand Down
27 changes: 21 additions & 6 deletions apps/eva_counselor_mobile/lib/core/widgets/custom_text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -28,6 +31,7 @@ class CustomTextField extends StatelessWidget {
this.maxLines = 1,
this.readOnly = false,
this.onTap,
this.fillColor,
});

@override
Expand All @@ -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),
),
),
),
],
Expand Down
38 changes: 37 additions & 1 deletion apps/eva_counselor_mobile/lib/core/widgets/status_badge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,62 @@ import 'auth_datasource.dart';

class MockAuthDataSource implements AuthDataSource {
bool _mockOnlineState = true;
final Map<String, PsychologistProfileResponseDto> _registeredAccounts = {};

@override
Future<PsychologistProfileResponseDto> 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,
);
}

@override
Future<PsychologistProfileResponseDto> 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,
Expand All @@ -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
Expand All @@ -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',
);
}

Expand Down
Loading