Merge pull request #437 from Oloodi/408-feature-implement-paidunpaid-breaks---client-app-frontend-development

FAQ feature implemented in the staff application
This commit is contained in:
Achintha Isuru
2026-02-19 00:48:34 -05:00
committed by GitHub
61 changed files with 1037 additions and 131 deletions

View File

@@ -65,6 +65,11 @@ This project uses a modular `Makefile` for all common tasks.
- **[03-contributing.md](./docs/03-contributing.md)**: Guidelines for new developers and setup checklist. - **[03-contributing.md](./docs/03-contributing.md)**: Guidelines for new developers and setup checklist.
- **[04-sync-prototypes.md](./docs/04-sync-prototypes.md)**: How to sync prototypes for local dev and AI context. - **[04-sync-prototypes.md](./docs/04-sync-prototypes.md)**: How to sync prototypes for local dev and AI context.
### Mobile Development Documentation
- **[MOBILE/01-architecture-principles.md](./docs/MOBILE/01-architecture-principles.md)**: Flutter clean architecture, package roles, and dependency flow.
- **[MOBILE/02-design-system-usage.md](./docs/MOBILE/02-design-system-usage.md)**: Design system components and theming guidelines.
- **[MOBILE/00-agent-development-rules.md](./docs/MOBILE/00-agent-development-rules.md)**: Rules and best practices for mobile development.
## 🤝 Contributing ## 🤝 Contributing
New to the team? Please read our **[Contributing Guide](./docs/03-contributing.md)** to get your environment set up and understand our workflow. New to the team? Please read our **[Contributing Guide](./docs/03-contributing.md)** to get your environment set up and understand our workflow.

View File

@@ -203,7 +203,9 @@ class StaffPaths {
static const String leaderboard = '/leaderboard'; static const String leaderboard = '/leaderboard';
/// FAQs - frequently asked questions. /// FAQs - frequently asked questions.
static const String faqs = '/faqs'; ///
/// Access to frequently asked questions about the staff application.
static const String faqs = '/worker-main/faqs/';
// ========================================================================== // ==========================================================================
// PRIVACY & SECURITY // PRIVACY & SECURITY

View File

@@ -521,7 +521,8 @@
"compliance": "COMPLIANCE", "compliance": "COMPLIANCE",
"level_up": "LEVEL UP", "level_up": "LEVEL UP",
"finance": "FINANCE", "finance": "FINANCE",
"support": "SUPPORT" "support": "SUPPORT",
"settings": "SETTINGS"
}, },
"menu_items": { "menu_items": {
"personal_info": "Personal Info", "personal_info": "Personal Info",
@@ -543,7 +544,8 @@
"timecard": "Timecard", "timecard": "Timecard",
"faqs": "FAQs", "faqs": "FAQs",
"privacy_security": "Privacy & Security", "privacy_security": "Privacy & Security",
"messages": "Messages" "messages": "Messages",
"language": "Language"
}, },
"bank_account_page": { "bank_account_page": {
"title": "Bank Account", "title": "Bank Account",
@@ -1143,6 +1145,12 @@
"profile_visibility_updated": "Profile visibility updated successfully!" "profile_visibility_updated": "Profile visibility updated successfully!"
} }
}, },
"staff_faqs": {
"title": "FAQs",
"search_placeholder": "Search questions...",
"no_results": "No matching questions found",
"contact_support": "Contact Support"
},
"success": { "success": {
"hub": { "hub": {
"created": "Hub created successfully!", "created": "Hub created successfully!",

View File

@@ -521,7 +521,8 @@
"compliance": "CUMPLIMIENTO", "compliance": "CUMPLIMIENTO",
"level_up": "MEJORAR NIVEL", "level_up": "MEJORAR NIVEL",
"finance": "FINANZAS", "finance": "FINANZAS",
"support": "SOPORTE" "support": "SOPORTE",
"settings": "AJUSTES"
}, },
"menu_items": { "menu_items": {
"personal_info": "Información Personal", "personal_info": "Información Personal",
@@ -543,7 +544,8 @@
"timecard": "Tarjeta de Tiempo", "timecard": "Tarjeta de Tiempo",
"faqs": "Preguntas Frecuentes", "faqs": "Preguntas Frecuentes",
"privacy_security": "Privacidad y Seguridad", "privacy_security": "Privacidad y Seguridad",
"messages": "Mensajes" "messages": "Mensajes",
"language": "Idioma"
}, },
"bank_account_page": { "bank_account_page": {
"title": "Cuenta Bancaria", "title": "Cuenta Bancaria",
@@ -1143,6 +1145,12 @@
"profile_visibility_updated": "¡Visibilidad del perfil actualizada exitosamente!" "profile_visibility_updated": "¡Visibilidad del perfil actualizada exitosamente!"
} }
}, },
"staff_faqs": {
"title": "Preguntas Frecuentes",
"search_placeholder": "Buscar preguntas...",
"no_results": "No se encontraron preguntas coincidentes",
"contact_support": "Contactar Soporte"
},
"success": { "success": {
"hub": { "hub": {
"created": "¡Hub creado exitosamente!", "created": "¡Hub creado exitosamente!",

View File

@@ -21,8 +21,8 @@ class UiColors {
/// Foreground color on primary background (#F7FAFC) /// Foreground color on primary background (#F7FAFC)
static const Color primaryForeground = Color(0xFFF7FAFC); static const Color primaryForeground = Color(0xFFF7FAFC);
/// Inverse primary color (#9FABF1) /// Inverse primary color (#0A39DF)
static const Color primaryInverse = Color(0xFF9FABF1); static const Color primaryInverse = Color.fromARGB(23, 10, 56, 223);
/// Secondary background color (#F1F3F5) /// Secondary background color (#F1F3F5)
static const Color secondary = Color(0xFFF1F3F5); static const Color secondary = Color(0xFFF1F3F5);

View File

@@ -264,4 +264,7 @@ class UiIcons {
/// Chef hat icon for attire /// Chef hat icon for attire
static const IconData chefHat = _IconLib.chefHat; static const IconData chefHat = _IconLib.chefHat;
/// Help circle icon for FAQs
static const IconData helpCircle = _IconLib.helpCircle;
} }

View File

@@ -49,13 +49,17 @@ class WorkerHomePage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
BlocBuilder<HomeCubit, HomeState>( BlocBuilder<HomeCubit, HomeState>(
buildWhen: (previous, current) => previous.staffName != current.staffName, buildWhen: (previous, current) =>
previous.staffName != current.staffName,
builder: (context, state) { builder: (context, state) {
return HomeHeader(userName: state.staffName); return HomeHeader(userName: state.staffName);
}, },
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space4,
),
child: Column( child: Column(
children: [ children: [
BlocBuilder<HomeCubit, HomeState>( BlocBuilder<HomeCubit, HomeState>(
@@ -67,7 +71,7 @@ class WorkerHomePage extends StatelessWidget {
return PlaceholderBanner( return PlaceholderBanner(
title: bannersI18n.complete_profile_title, title: bannersI18n.complete_profile_title,
subtitle: bannersI18n.complete_profile_subtitle, subtitle: bannersI18n.complete_profile_subtitle,
bg: UiColors.bgHighlight, bg: UiColors.primaryInverse,
accent: UiColors.primary, accent: UiColors.primary,
onTap: () { onTap: () {
Modular.to.toProfile(); Modular.to.toProfile();
@@ -135,7 +139,8 @@ class WorkerHomePage extends StatelessWidget {
EmptyStateWidget( EmptyStateWidget(
message: emptyI18n.no_shifts_today, message: emptyI18n.no_shifts_today,
actionLink: emptyI18n.find_shifts_cta, actionLink: emptyI18n.find_shifts_cta,
onAction: () => Modular.to.toShifts(initialTab: 'find'), onAction: () =>
Modular.to.toShifts(initialTab: 'find'),
) )
else else
Column( Column(
@@ -183,9 +188,7 @@ class WorkerHomePage extends StatelessWidget {
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
// Recommended Shifts // Recommended Shifts
SectionHeader( SectionHeader(title: sectionsI18n.recommended_for_you),
title: sectionsI18n.recommended_for_you,
),
BlocBuilder<HomeCubit, HomeState>( BlocBuilder<HomeCubit, HomeState>(
builder: (context, state) { builder: (context, state) {
if (state.recommendedShifts.isEmpty) { if (state.recommendedShifts.isEmpty) {
@@ -201,7 +204,8 @@ class WorkerHomePage extends StatelessWidget {
clipBehavior: Clip.none, clipBehavior: Clip.none,
itemBuilder: (context, index) => Padding( itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
right: UiConstants.space3), right: UiConstants.space3,
),
child: RecommendedShiftCard( child: RecommendedShiftCard(
shift: state.recommendedShifts[index], shift: state.recommendedShifts[index],
), ),

View File

@@ -1,24 +1,33 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
/// Banner widget for placeholder actions, using design system tokens. /// Banner widget for placeholder actions, using design system tokens.
class PlaceholderBanner extends StatelessWidget { class PlaceholderBanner extends StatelessWidget {
/// Banner title /// Banner title
final String title; final String title;
/// Banner subtitle /// Banner subtitle
final String subtitle; final String subtitle;
/// Banner background color /// Banner background color
final Color bg; final Color bg;
/// Banner accent color /// Banner accent color
final Color accent; final Color accent;
/// Optional tap callback /// Optional tap callback
final VoidCallback? onTap; final VoidCallback? onTap;
/// Creates a [PlaceholderBanner]. /// Creates a [PlaceholderBanner].
const PlaceholderBanner({super.key, required this.title, required this.subtitle, required this.bg, required this.accent, this.onTap}); const PlaceholderBanner({
super.key,
required this.title,
required this.subtitle,
required this.bg,
required this.accent,
this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -29,7 +38,7 @@ class PlaceholderBanner extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: accent.withValues(alpha: 0.3)), border: Border.all(color: accent, width: 1),
), ),
child: Row( child: Row(
children: [ children: [
@@ -41,7 +50,11 @@ class PlaceholderBanner extends StatelessWidget {
color: UiColors.bgBanner, color: UiColors.bgBanner,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(UiIcons.sparkles, color: accent, size: UiConstants.space5), child: Icon(
UiIcons.sparkles,
color: accent,
size: UiConstants.space5,
),
), ),
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
@@ -50,12 +63,9 @@ class PlaceholderBanner extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: UiTypography.body1b, style: UiTypography.body1b.copyWith(color: accent),
),
Text(
subtitle,
style: UiTypography.body3r.copyWith(color: UiColors.mutedForeground),
), ),
Text(subtitle, style: UiTypography.body3r.textSecondary),
], ],
), ),
), ),

View File

@@ -8,14 +8,11 @@ import 'package:krow_domain/krow_domain.dart';
import '../blocs/profile_cubit.dart'; import '../blocs/profile_cubit.dart';
import '../blocs/profile_state.dart'; import '../blocs/profile_state.dart';
import '../widgets/language_selector_bottom_sheet.dart';
import '../widgets/logout_button.dart'; import '../widgets/logout_button.dart';
import '../widgets/profile_header.dart'; import '../widgets/profile_header.dart';
import '../widgets/profile_menu_grid.dart';
import '../widgets/profile_menu_item.dart';
import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_score_bar.dart';
import '../widgets/reliability_stats_card.dart'; import '../widgets/reliability_stats_card.dart';
import '../widgets/section_title.dart'; import '../widgets/sections/index.dart';
/// The main Staff Profile page. /// The main Staff Profile page.
/// ///
@@ -49,7 +46,6 @@ class StaffProfilePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = Translations.of(context).staff.profile;
final ProfileCubit cubit = Modular.get<ProfileCubit>(); final ProfileCubit cubit = Modular.get<ProfileCubit>();
// Load profile data on first build // Load profile data on first build
@@ -60,7 +56,7 @@ class StaffProfilePage extends StatelessWidget {
return Scaffold( return Scaffold(
body: BlocConsumer<ProfileCubit, ProfileState>( body: BlocConsumer<ProfileCubit, ProfileState>(
bloc: cubit, bloc: cubit,
listener: (context, state) { listener: (BuildContext context, ProfileState state) {
if (state.status == ProfileStatus.signedOut) { if (state.status == ProfileStatus.signedOut) {
Modular.to.toGetStartedPage(); Modular.to.toGetStartedPage();
} else if (state.status == ProfileStatus.error && } else if (state.status == ProfileStatus.error &&
@@ -72,7 +68,7 @@ class StaffProfilePage extends StatelessWidget {
); );
} }
}, },
builder: (context, state) { builder: (BuildContext context, ProfileState state) {
// Show loading spinner if status is loading // Show loading spinner if status is loading
if (state.status == ProfileStatus.loading) { if (state.status == ProfileStatus.loading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -95,7 +91,7 @@ class StaffProfilePage extends StatelessWidget {
); );
} }
final profile = state.profile; final Staff? profile = state.profile;
if (profile == null) { if (profile == null) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -103,7 +99,7 @@ class StaffProfilePage extends StatelessWidget {
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: UiConstants.space16), padding: const EdgeInsets.only(bottom: UiConstants.space16),
child: Column( child: Column(
children: [ children: <Widget>[
ProfileHeader( ProfileHeader(
fullName: profile.name, fullName: profile.name,
level: _mapStatusToLevel(profile.status), level: _mapStatusToLevel(profile.status),
@@ -117,7 +113,7 @@ class StaffProfilePage extends StatelessWidget {
horizontal: UiConstants.space5, horizontal: UiConstants.space5,
), ),
child: Column( child: Column(
children: [ children: <Widget>[
ReliabilityStatsCard( ReliabilityStatsCard(
totalShifts: profile.totalShifts, totalShifts: profile.totalShifts,
averageRating: profile.averageRating, averageRating: profile.averageRating,
@@ -130,100 +126,15 @@ class StaffProfilePage extends StatelessWidget {
reliabilityScore: profile.reliabilityScore, reliabilityScore: profile.reliabilityScore,
), ),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
SectionTitle(i18n.sections.onboarding), const OnboardingSection(),
ProfileMenuGrid(
crossAxisCount: 3,
children: [
ProfileMenuItem(
icon: UiIcons.user,
label: i18n.menu_items.personal_info,
onTap: () => Modular.to.toPersonalInfo(),
),
ProfileMenuItem(
icon: UiIcons.phone,
label: i18n.menu_items.emergency_contact,
onTap: () => Modular.to.toEmergencyContact(),
),
ProfileMenuItem(
icon: UiIcons.briefcase,
label: i18n.menu_items.experience,
onTap: () => Modular.to.toExperience(),
),
],
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Column( const ComplianceSection(),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(i18n.sections.compliance),
ProfileMenuGrid(
crossAxisCount: 3,
children: [
ProfileMenuItem(
icon: UiIcons.file,
label: i18n.menu_items.tax_forms,
onTap: () => Modular.to.toTaxForms(),
),
],
),
],
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
SectionTitle(i18n.sections.finance), const FinanceSection(),
ProfileMenuGrid(
crossAxisCount: 3,
children: [
ProfileMenuItem(
icon: UiIcons.building,
label: i18n.menu_items.bank_account,
onTap: () => Modular.to.toBankAccount(),
),
ProfileMenuItem(
icon: UiIcons.creditCard,
label: i18n.menu_items.payments,
onTap: () => Modular.to.toPayments(),
),
ProfileMenuItem(
icon: UiIcons.clock,
label: i18n.menu_items.timecard,
onTap: () => Modular.to.toTimeCard(),
),
],
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
SectionTitle( const SupportSection(),
i18n.header.title.contains("Perfil") ? "Soporte" : "Support",
),
ProfileMenuGrid(
crossAxisCount: 3,
children: [
ProfileMenuItem(
icon: UiIcons.shield,
label: i18n.header.title.contains("Perfil") ? "Privacidad" : "Privacy & Security",
onTap: () => Modular.to.toPrivacySecurity(),
),
],
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
SectionTitle( const SettingsSection(),
i18n.header.title.contains("Perfil") ? "Ajustes" : "Settings",
),
ProfileMenuGrid(
crossAxisCount: 3,
children: [
ProfileMenuItem(
icon: UiIcons.globe,
label: i18n.header.title.contains("Perfil") ? "Idioma" : "Language",
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => const LanguageSelectorBottomSheet(),
);
},
),
],
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
LogoutButton( LogoutButton(
onTap: () => _onSignOut(cubit, state), onTap: () => _onSignOut(cubit, state),

View File

@@ -0,0 +1,39 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../profile_menu_grid.dart';
import '../profile_menu_item.dart';
import '../section_title.dart';
/// Widget displaying the compliance section of the staff profile.
///
/// This section contains menu items for tax forms and other compliance-related documents.
class ComplianceSection extends StatelessWidget {
/// Creates a [ComplianceSection].
const ComplianceSection({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SectionTitle(i18n.sections.compliance),
ProfileMenuGrid(
crossAxisCount: 3,
children: <Widget>[
ProfileMenuItem(
icon: UiIcons.file,
label: i18n.menu_items.tax_forms,
onTap: () => Modular.to.toTaxForms(),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../profile_menu_grid.dart';
import '../profile_menu_item.dart';
import '../section_title.dart';
/// Widget displaying the finance section of the staff profile.
///
/// This section contains menu items for bank account, payments, and timecard information.
class FinanceSection extends StatelessWidget {
/// Creates a [FinanceSection].
const FinanceSection({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
return Column(
children: <Widget>[
SectionTitle(i18n.sections.finance),
ProfileMenuGrid(
crossAxisCount: 3,
children: <Widget>[
ProfileMenuItem(
icon: UiIcons.building,
label: i18n.menu_items.bank_account,
onTap: () => Modular.to.toBankAccount(),
),
ProfileMenuItem(
icon: UiIcons.creditCard,
label: i18n.menu_items.payments,
onTap: () => Modular.to.toPayments(),
),
ProfileMenuItem(
icon: UiIcons.clock,
label: i18n.menu_items.timecard,
onTap: () => Modular.to.toTimeCard(),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,5 @@
export 'compliance_section.dart';
export 'finance_section.dart';
export 'onboarding_section.dart';
export 'settings_section.dart';
export 'support_section.dart';

View File

@@ -0,0 +1,49 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../profile_menu_grid.dart';
import '../profile_menu_item.dart';
import '../section_title.dart';
/// Widget displaying the onboarding section of the staff profile.
///
/// This section contains menu items for personal information, emergency contact,
/// and work experience setup.
class OnboardingSection extends StatelessWidget {
/// Creates an [OnboardingSection].
const OnboardingSection({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile;
return Column(
children: <Widget>[
SectionTitle(i18n.sections.onboarding),
ProfileMenuGrid(
crossAxisCount: 3,
children: <Widget>[
ProfileMenuItem(
icon: UiIcons.user,
label: i18n.menu_items.personal_info,
onTap: () => Modular.to.toPersonalInfo(),
),
ProfileMenuItem(
icon: UiIcons.phone,
label: i18n.menu_items.emergency_contact,
onTap: () => Modular.to.toEmergencyContact(),
),
ProfileMenuItem(
icon: UiIcons.briefcase,
label: i18n.menu_items.experience,
onTap: () => Modular.to.toExperience(),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../language_selector_bottom_sheet.dart';
import '../profile_menu_grid.dart';
import '../profile_menu_item.dart';
import '../section_title.dart';
/// Widget displaying the settings section of the staff profile.
///
/// This section contains menu items for language selection.
class SettingsSection extends StatelessWidget {
/// Creates a [SettingsSection].
const SettingsSection({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(
context,
).staff.profile;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SectionTitle(i18n.sections.settings),
ProfileMenuGrid(
crossAxisCount: 3,
children: <Widget>[
ProfileMenuItem(
icon: UiIcons.globe,
label: i18n.menu_items.language,
onTap: () {
showModalBottomSheet(
context: context,
builder: (BuildContext context) =>
const LanguageSelectorBottomSheet(),
);
},
),
],
),
],
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../profile_menu_grid.dart';
import '../profile_menu_item.dart';
import '../section_title.dart';
/// Widget displaying the support section of the staff profile.
///
/// This section contains menu items for FAQs and privacy & security settings.
class SupportSection extends StatelessWidget {
/// Creates a [SupportSection].
const SupportSection({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = Translations.of(
context,
).staff.profile;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SectionTitle(i18n.sections.support),
ProfileMenuGrid(
crossAxisCount: 3,
children: <Widget>[
ProfileMenuItem(
icon: UiIcons.helpCircle,
label: i18n.menu_items.faqs,
onTap: () => Modular.to.toFaqs(),
),
ProfileMenuItem(
icon: UiIcons.shield,
label: i18n.menu_items.privacy_security,
onTap: () => Modular.to.toPrivacySecurity(),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,53 @@
[
{
"category": "Getting Started",
"questions": [
{
"q": "How do I apply for shifts?",
"a": "Browse available shifts on the Shifts tab and tap \"Accept\" on any shift that interests you. Once confirmed, you'll receive all the details you need."
},
{
"q": "How do I get paid?",
"a": "Payments are processed weekly via direct deposit to your linked bank account. You can view your earnings in the Payments section."
},
{
"q": "What if I need to cancel a shift?",
"a": "You can cancel a shift up to 24 hours before it starts without penalty. Late cancellations may affect your reliability score."
}
]
},
{
"category": "Shifts & Work",
"questions": [
{
"q": "How do I clock in?",
"a": "Use the Clock In feature on the home screen when you arrive at your shift. Make sure location services are enabled for verification."
},
{
"q": "What should I wear?",
"a": "Check the shift details for dress code requirements. You can manage your wardrobe in the Attire section of your profile."
},
{
"q": "Who do I contact if I'm running late?",
"a": "Use the \"Running Late\" feature in the app to notify the client. You can also message the shift manager directly."
}
]
},
{
"category": "Payments & Earnings",
"questions": [
{
"q": "When do I get paid?",
"a": "Payments are processed every Friday for shifts completed the previous week. Funds typically arrive within 1-2 business days."
},
{
"q": "How do I update my bank account?",
"a": "Go to Profile > Finance > Bank Account to add or update your banking information."
},
{
"q": "Where can I find my tax documents?",
"a": "Tax documents (1099) are available in Profile > Compliance > Tax Documents by January 31st each year."
}
]
}
]

View File

@@ -0,0 +1,101 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import '../../domain/entities/faq_category.dart';
import '../../domain/entities/faq_item.dart';
import '../../domain/repositories/faqs_repository_interface.dart';
/// Data layer implementation of FAQs repository
///
/// Handles loading FAQs from app assets (JSON file)
class FaqsRepositoryImpl implements FaqsRepositoryInterface {
/// Private cache for FAQs to avoid reloading from assets multiple times
List<FaqCategory>? _cachedFaqs;
@override
Future<List<FaqCategory>> getFaqs() async {
try {
// Return cached FAQs if available
if (_cachedFaqs != null) {
return _cachedFaqs!;
}
// Load FAQs from JSON asset
final String faqsJson = await rootBundle.loadString(
'packages/staff_faqs/lib/src/assets/faqs/faqs.json',
);
// Parse JSON
final List<dynamic> decoded = jsonDecode(faqsJson) as List<dynamic>;
// Convert to domain entities
_cachedFaqs = decoded.map((dynamic item) {
final Map<String, dynamic> category = item as Map<String, dynamic>;
final String categoryName = category['category'] as String;
final List<dynamic> questionsData =
category['questions'] as List<dynamic>;
final List<FaqItem> questions = questionsData.map((dynamic q) {
final Map<String, dynamic> questionMap = q as Map<String, dynamic>;
return FaqItem(
question: questionMap['q'] as String,
answer: questionMap['a'] as String,
);
}).toList();
return FaqCategory(
category: categoryName,
questions: questions,
);
}).toList();
return _cachedFaqs!;
} catch (e) {
// Return empty list on error
return <FaqCategory>[];
}
}
@override
Future<List<FaqCategory>> searchFaqs(String query) async {
try {
// Get all FAQs first
final List<FaqCategory> allFaqs = await getFaqs();
if (query.isEmpty) {
return allFaqs;
}
final String lowerQuery = query.toLowerCase();
// Filter categories based on matching questions
final List<FaqCategory> filtered = allFaqs
.map((FaqCategory category) {
// Filter questions that match the query
final List<FaqItem> matchingQuestions =
category.questions.where((FaqItem item) {
final String questionLower = item.question.toLowerCase();
final String answerLower = item.answer.toLowerCase();
return questionLower.contains(lowerQuery) ||
answerLower.contains(lowerQuery);
}).toList();
// Only include category if it has matching questions
if (matchingQuestions.isNotEmpty) {
return FaqCategory(
category: category.category,
questions: matchingQuestions,
);
}
return null;
})
.whereType<FaqCategory>()
.toList();
return filtered;
} catch (e) {
return <FaqCategory>[];
}
}
}

View File

@@ -0,0 +1,20 @@
import 'package:equatable/equatable.dart';
import 'faq_item.dart';
/// Entity representing an FAQ category with its questions
class FaqCategory extends Equatable {
/// The category name (e.g., "Getting Started", "Shifts & Work")
final String category;
/// List of FAQ items in this category
final List<FaqItem> questions;
const FaqCategory({
required this.category,
required this.questions,
});
@override
List<Object?> get props => <Object?>[category, questions];
}

View File

@@ -0,0 +1,18 @@
import 'package:equatable/equatable.dart';
/// Entity representing a single FAQ question and answer
class FaqItem extends Equatable {
/// The question text
final String question;
/// The answer text
final String answer;
const FaqItem({
required this.question,
required this.answer,
});
@override
List<Object?> get props => <Object?>[question, answer];
}

View File

@@ -0,0 +1,11 @@
import '../entities/faq_category.dart';
/// Interface for FAQs repository operations
abstract class FaqsRepositoryInterface {
/// Fetch all FAQ categories with their questions
Future<List<FaqCategory>> getFaqs();
/// Search FAQs by query string
/// Returns categories that contain matching questions
Future<List<FaqCategory>> searchFaqs(String query);
}

View File

@@ -0,0 +1,19 @@
import '../entities/faq_category.dart';
import '../repositories/faqs_repository_interface.dart';
/// Use case to retrieve all FAQs
class GetFaqsUseCase {
final FaqsRepositoryInterface _repository;
GetFaqsUseCase(this._repository);
/// Execute the use case to get all FAQ categories
Future<List<FaqCategory>> call() async {
try {
return await _repository.getFaqs();
} catch (e) {
// Return empty list on error
return <FaqCategory>[];
}
}
}

View File

@@ -0,0 +1,27 @@
import '../entities/faq_category.dart';
import '../repositories/faqs_repository_interface.dart';
/// Parameters for search FAQs use case
class SearchFaqsParams {
/// Search query string
final String query;
SearchFaqsParams({required this.query});
}
/// Use case to search FAQs by query
class SearchFaqsUseCase {
final FaqsRepositoryInterface _repository;
SearchFaqsUseCase(this._repository);
/// Execute the use case to search FAQs
Future<List<FaqCategory>> call(SearchFaqsParams params) async {
try {
return await _repository.searchFaqs(params.query);
} catch (e) {
// Return empty list on error
return <FaqCategory>[];
}
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../domain/entities/faq_category.dart';
import '../../domain/usecases/get_faqs_usecase.dart';
import '../../domain/usecases/search_faqs_usecase.dart';
part 'faqs_event.dart';
part 'faqs_state.dart';
/// BLoC managing FAQs state
class FaqsBloc extends Bloc<FaqsEvent, FaqsState> {
final GetFaqsUseCase _getFaqsUseCase;
final SearchFaqsUseCase _searchFaqsUseCase;
FaqsBloc({
required GetFaqsUseCase getFaqsUseCase,
required SearchFaqsUseCase searchFaqsUseCase,
}) : _getFaqsUseCase = getFaqsUseCase,
_searchFaqsUseCase = searchFaqsUseCase,
super(const FaqsState()) {
on<FetchFaqsEvent>(_onFetchFaqs);
on<SearchFaqsEvent>(_onSearchFaqs);
}
Future<void> _onFetchFaqs(
FetchFaqsEvent event,
Emitter<FaqsState> emit,
) async {
emit(state.copyWith(isLoading: true, error: null));
try {
final List<FaqCategory> categories = await _getFaqsUseCase.call();
emit(
state.copyWith(
isLoading: false,
categories: categories,
searchQuery: '',
),
);
} catch (e) {
emit(
state.copyWith(
isLoading: false,
error: 'Failed to load FAQs',
),
);
}
}
Future<void> _onSearchFaqs(
SearchFaqsEvent event,
Emitter<FaqsState> emit,
) async {
emit(state.copyWith(isLoading: true, error: null, searchQuery: event.query));
try {
final List<FaqCategory> results = await _searchFaqsUseCase.call(
SearchFaqsParams(query: event.query),
);
emit(
state.copyWith(
isLoading: false,
categories: results,
),
);
} catch (e) {
emit(
state.copyWith(
isLoading: false,
error: 'Failed to search FAQs',
),
);
}
}
}

View File

@@ -0,0 +1,25 @@
part of 'faqs_bloc.dart';
/// Base class for FAQs BLoC events
abstract class FaqsEvent extends Equatable {
const FaqsEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Event to fetch all FAQs
class FetchFaqsEvent extends FaqsEvent {
const FetchFaqsEvent();
}
/// Event to search FAQs by query
class SearchFaqsEvent extends FaqsEvent {
/// Search query string
final String query;
const SearchFaqsEvent({required this.query});
@override
List<Object?> get props => <Object?>[query];
}

View File

@@ -0,0 +1,46 @@
part of 'faqs_bloc.dart';
/// State for FAQs BLoC
class FaqsState extends Equatable {
/// List of FAQ categories currently displayed
final List<FaqCategory> categories;
/// Whether FAQs are currently loading
final bool isLoading;
/// Current search query
final String searchQuery;
/// Error message, if any
final String? error;
const FaqsState({
this.categories = const <FaqCategory>[],
this.isLoading = false,
this.searchQuery = '',
this.error,
});
/// Create a copy with optional field overrides
FaqsState copyWith({
List<FaqCategory>? categories,
bool? isLoading,
String? searchQuery,
String? error,
}) {
return FaqsState(
categories: categories ?? this.categories,
isLoading: isLoading ?? this.isLoading,
searchQuery: searchQuery ?? this.searchQuery,
error: error,
);
}
@override
List<Object?> get props => <Object?>[
categories,
isLoading,
searchQuery,
error,
];
}

View File

@@ -0,0 +1,32 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/faqs_bloc.dart';
import '../widgets/faqs_widget.dart';
/// Page displaying frequently asked questions
class FaqsPage extends StatelessWidget {
const FaqsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: UiAppBar(
title: t.staff_faqs.title,
showBackButton: true,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(color: UiColors.border, height: 1),
),
),
body: BlocProvider<FaqsBloc>(
create: (BuildContext context) =>
Modular.get<FaqsBloc>()..add(const FetchFaqsEvent()),
child: const Stack(children: <Widget>[FaqsWidget()]),
),
);
}
}

View File

@@ -0,0 +1,194 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:staff_faqs/src/presentation/blocs/faqs_bloc.dart';
/// Widget displaying FAQs with search functionality and accordion items
class FaqsWidget extends StatefulWidget {
const FaqsWidget({super.key});
@override
State<FaqsWidget> createState() => _FaqsWidgetState();
}
class _FaqsWidgetState extends State<FaqsWidget> {
late TextEditingController _searchController;
final Map<String, bool> _openItems = <String, bool>{};
@override
void initState() {
super.initState();
_searchController = TextEditingController();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _toggleItem(String key) {
setState(() {
_openItems[key] = !(_openItems[key] ?? false);
});
}
void _onSearchChanged(String value) {
if (value.isEmpty) {
context.read<FaqsBloc>().add(const FetchFaqsEvent());
} else {
context.read<FaqsBloc>().add(SearchFaqsEvent(query: value));
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<FaqsBloc, FaqsState>(
builder: (BuildContext context, FaqsState state) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 100),
child: Column(
children: <Widget>[
// Search Bar
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: t.staff_faqs.search_placeholder,
hintStyle: const TextStyle(color: UiColors.textPlaceholder),
prefixIcon: const Icon(
UiIcons.search,
color: UiColors.textSecondary,
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(height: 24),
// FAQ List or Empty State
if (state.isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: CircularProgressIndicator(),
)
else if (state.categories.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: <Widget>[
const Icon(
UiIcons.helpCircle,
size: 48,
color: UiColors.textSecondary,
),
const SizedBox(height: 12),
Text(
t.staff_faqs.no_results,
style: const TextStyle(color: UiColors.textSecondary),
),
],
),
)
else
...state.categories.asMap().entries.map((
MapEntry<int, dynamic> entry,
) {
final int catIndex = entry.key;
final dynamic categoryItem = entry.value;
final String categoryName = categoryItem.category;
final List<dynamic> questions = categoryItem.questions;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
categoryName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 12),
...questions.asMap().entries.map((
MapEntry<int, dynamic> qEntry,
) {
final int qIndex = qEntry.key;
final dynamic questionItem = qEntry.value;
final String key = '$catIndex-$qIndex';
final bool isOpen = _openItems[key] ?? false;
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
border: Border.all(color: UiColors.border),
),
child: Column(
children: <Widget>[
InkWell(
onTap: () => _toggleItem(key),
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: <Widget>[
Expanded(
child: Text(
questionItem.question,
style: UiTypography.body1r,
),
),
Icon(
isOpen
? UiIcons.chevronUp
: UiIcons.chevronDown,
color: UiColors.textSecondary,
size: 20,
),
],
),
),
),
if (isOpen)
Padding(
padding: const EdgeInsets.fromLTRB(
16,
0,
16,
16,
),
child: Text(
questionItem.answer,
style: UiTypography.body1r.textSecondary,
),
),
],
),
);
}),
const SizedBox(height: 12),
],
);
}),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'data/repositories_impl/faqs_repository_impl.dart';
import 'domain/repositories/faqs_repository_interface.dart';
import 'domain/usecases/get_faqs_usecase.dart';
import 'domain/usecases/search_faqs_usecase.dart';
import 'presentation/blocs/faqs_bloc.dart';
import 'presentation/pages/faqs_page.dart';
/// Module for FAQs feature
///
/// Provides:
/// - Dependency injection for repositories, use cases, and BLoCs
/// - Route definitions delegated to core routing
class FaqsModule extends Module {
@override
void binds(Injector i) {
// Repository
i.addSingleton<FaqsRepositoryInterface>(
() => FaqsRepositoryImpl(),
);
// Use Cases
i.addSingleton(
() => GetFaqsUseCase(
i<FaqsRepositoryInterface>(),
),
);
i.addSingleton(
() => SearchFaqsUseCase(
i<FaqsRepositoryInterface>(),
),
);
// BLoC
i.add(
() => FaqsBloc(
getFaqsUseCase: i<GetFaqsUseCase>(),
searchFaqsUseCase: i<SearchFaqsUseCase>(),
),
);
}
@override
void routes(RouteManager r) {
r.child(
StaffPaths.childRoute(StaffPaths.faqs, StaffPaths.faqs),
child: (_) => const FaqsPage(),
);
}
}

View File

@@ -0,0 +1,4 @@
library staff_faqs;
export 'src/staff_faqs_module.dart';
export 'src/presentation/pages/faqs_page.dart';

View File

@@ -0,0 +1,29 @@
name: staff_faqs
description: Frequently Asked Questions feature for staff application.
version: 0.0.1
publish_to: none
resolution: workspace
environment:
sdk: '>=3.10.0 <4.0.0'
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
# Architecture Packages
krow_core:
path: ../../../../../core
design_system:
path: ../../../../../design_system
core_localization:
path: ../../../../../core_localization
flutter:
uses-material-design: true
assets:
- lib/src/assets/faqs/

View File

@@ -18,6 +18,7 @@ import 'package:staff_profile_experience/staff_profile_experience.dart';
import 'package:staff_profile_info/staff_profile_info.dart'; import 'package:staff_profile_info/staff_profile_info.dart';
import 'package:staff_shifts/staff_shifts.dart'; import 'package:staff_shifts/staff_shifts.dart';
import 'package:staff_tax_forms/staff_tax_forms.dart'; import 'package:staff_tax_forms/staff_tax_forms.dart';
import 'package:staff_faqs/staff_faqs.dart';
import 'package:staff_time_card/staff_time_card.dart'; import 'package:staff_time_card/staff_time_card.dart';
class StaffMainModule extends Module { class StaffMainModule extends Module {
@@ -102,5 +103,9 @@ class StaffMainModule extends Module {
StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute), StaffPaths.childRoute(StaffPaths.main, StaffPaths.shiftDetailsRoute),
module: ShiftDetailsModule(), module: ShiftDetailsModule(),
); );
r.module(
StaffPaths.childRoute(StaffPaths.main, StaffPaths.faqs),
module: FaqsModule(),
);
} }
} }

View File

@@ -55,7 +55,9 @@ dependencies:
staff_clock_in: staff_clock_in:
path: ../clock_in path: ../clock_in
staff_privacy_security: staff_privacy_security:
path: ../profile_sections/settings/privacy_security path: ../profile_sections/support/privacy_security
staff_faqs:
path: ../profile_sections/support/faqs
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -1290,10 +1290,17 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.1" version: "1.12.1"
staff_faqs:
dependency: transitive
description:
path: "packages/features/staff/profile_sections/support/faqs"
relative: true
source: path
version: "0.0.1"
staff_privacy_security: staff_privacy_security:
dependency: transitive dependency: transitive
description: description:
path: "packages/features/staff/profile_sections/settings/privacy_security" path: "packages/features/staff/profile_sections/support/privacy_security"
relative: true relative: true
source: path source: path
version: "0.0.1" version: "0.0.1"

View File

@@ -59,7 +59,7 @@ The application is broken down into several key functional modules:
| Component | Primary Responsibility | Example Task | | Component | Primary Responsibility | Example Task |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **Router (GoRouter)** | Navigation traffic cop | Directs the user from the "Login" screen to the "Home" dashboard upon success. | | **Router (Flutter Modular)** | Navigation traffic cop | Directs the user from the "Login" screen to the "Home" dashboard upon success. |
| **Screens (UI)** | Displaying information | Renders the "Create Order" form and captures the user's input for date and time. | | **Screens (UI)** | Displaying information | Renders the "Create Order" form and captures the user's input for date and time. |
| **Providers (Riverpod)** | Data management & State | Holds the list of today's active shifts so multiple screens can access it without reloading. | | **Providers (Riverpod)** | Data management & State | Holds the list of today's active shifts so multiple screens can access it without reloading. |
| **Widgets** | Reusable UI building blocks | A "Shift Card" widget that displays shift details effectively, used in multiple lists throughout the app. | | **Widgets** | Reusable UI building blocks | A "Shift Card" widget that displays shift details effectively, used in multiple lists throughout the app. |
@@ -91,7 +91,7 @@ While currently operating as a high-fidelity prototype with mock data, the archi
## 8. Key Design Decisions ## 8. Key Design Decisions
* **Flutter Framework:** chosen for its ability to produce high-performance, native-feeling apps for both iOS and Android from a single codebase, reducing development time and cost. * **Flutter Framework:** chosen for its ability to produce high-performance, native-feeling apps for both iOS and Android from a single codebase, reducing development time and cost.
* **GoRouter for Navigation:** A modern routing package that handles complex navigation scenarios (like deep linking and sub-routes) which are essential for a multi-layered app like this. * **Flutter Modular for Navigation:** A modern routing package that handles complex navigation scenarios (like deep linking and sub-routes) which are essential for a multi-layered app like this.
* **Riverpod for State Management:** A robust solution that catches programming errors at compile-time (while writing code) rather than run-time (while using the app), increasing app stability. * **Riverpod for State Management:** A robust solution that catches programming errors at compile-time (while writing code) rather than run-time (while using the app), increasing app stability.
* **Mock Data Services:** The decision to use extensive mock data allows for rapid UI/UX iteration and testing of business flows without waiting for the full backend infrastructure to be built. * **Mock Data Services:** The decision to use extensive mock data allows for rapid UI/UX iteration and testing of business flows without waiting for the full backend infrastructure to be built.
@@ -102,7 +102,7 @@ flowchart TD
direction TB direction TB
subgraph PresentationLayer["Presentation Layer (UI)"] subgraph PresentationLayer["Presentation Layer (UI)"]
direction TB direction TB
Router["GoRouter Navigation"] Router["Flutter Modular Navigation"]
subgraph FeatureModules["Feature Modules"] subgraph FeatureModules["Feature Modules"]
AuthUI["Auth Screens"] AuthUI["Auth Screens"]
DashUI["Dashboard & Home"] DashUI["Dashboard & Home"]

View File

@@ -98,7 +98,7 @@ flowchart TD
direction TB direction TB
subgraph PresentationLayer["Presentation Layer (UI)"] subgraph PresentationLayer["Presentation Layer (UI)"]
direction TB direction TB
Router["GoRouter Navigation"] Router["Flutter Modular Navigation"]
subgraph FeatureModules["Feature Modules"] subgraph FeatureModules["Feature Modules"]
AuthUI["Auth & Onboarding"] AuthUI["Auth & Onboarding"]
MarketUI["Marketplace & Jobs"] MarketUI["Marketplace & Jobs"]