diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 9fb4251f..beef30fb 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1143,6 +1143,12 @@ "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": { "hub": { "created": "Hub created successfully!", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 77370c8e..80df41d0 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1143,6 +1143,12 @@ "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": { "hub": { "created": "¡Hub creado exitosamente!", diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/README.md b/apps/mobile/packages/features/staff/profile_sections/support/faqs/README.md new file mode 100644 index 00000000..5213dad3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/README.md @@ -0,0 +1,122 @@ +# Staff FAQs Feature + +A modular feature package providing Frequently Asked Questions functionality for the staff mobile application. + +## Architecture + +This package follows clean architecture principles with clear separation of concerns: + +### Domain Layer (`lib/src/domain/`) +- **Entities**: `FaqCategory`, `FaqItem` - Domain models representing FAQ data +- **Repositories**: `FaqsRepositoryInterface` - Abstract interface for FAQ data access +- **Use Cases**: + - `GetFaqsUseCase` - Retrieves all FAQs + - `SearchFaqsUseCase` - Searches FAQs by query + +### Data Layer (`lib/src/data/`) +- **Repositories Implementation**: `FaqsRepositoryImpl` + - Loads FAQs from JSON asset file (`lib/src/assets/faqs/faqs.json`) + - Implements caching to avoid repeated file reads + - Provides search filtering logic + +### Presentation Layer (`lib/src/presentation/`) +- **BLoC**: `FaqsBloc` - State management with events and states + - Events: `FetchFaqsEvent`, `SearchFaqsEvent` + - State: `FaqsState` - Manages categories, loading, search query, and errors +- **Pages**: `FaqsPage` - Full-screen FAQ view with AppBar and contact button +- **Widgets**: `FaqsWidget` - Reusable accordion widget with search functionality + +## Features + +✅ **Search Functionality** - Real-time search across questions and answers +✅ **Accordion UI** - Expandable/collapsible FAQ items +✅ **Category Organization** - FAQs grouped by category +✅ **Localization** - Support for English and Spanish +✅ **Contact Support Button** - Direct link to messaging/support +✅ **Empty State** - Helpful message when no results found +✅ **Loading State** - Loading indicator while fetching FAQs +✅ **Asset-based Data** - FAQ content stored in JSON for easy updates + +## Data Structure + +FAQs are stored in `lib/src/assets/faqs/faqs.json` with the following structure: + +```json +[ + { + "category": "Getting Started", + "questions": [ + { + "q": "How do I apply for shifts?", + "a": "Browse available shifts..." + } + ] + } +] +``` + +## Localization + +Localization strings are defined in: +- `packages/core_localization/lib/src/l10n/en.i18n.json` +- `packages/core_localization/lib/src/l10n/es.i18n.json` + +Available keys: +- `staff_faqs.title` - Page title +- `staff_faqs.search_placeholder` - Search input hint +- `staff_faqs.no_results` - Empty state message +- `staff_faqs.contact_support` - Button label + +## Dependency Injection + +The `FaqsModule` provides all dependencies: + +```dart +FaqsModule().binds(injector); +``` + +Registered singletons: +- `FaqsRepositoryInterface` → `FaqsRepositoryImpl` +- `GetFaqsUseCase` +- `SearchFaqsUseCase` +- `FaqsBloc` + +## Routing + +Routes are defined in `FaqsModule.routes()`: +- `/` → `FaqsPage` + +## Usage + +1. Add `FaqsModule` to your modular configuration +2. Access the page via routing: `context.push('/faqs')` +3. The BLoC will automatically fetch FAQs on page load + +## Asset Configuration + +Update `pubspec.yaml` to include assets: + +```yaml +flutter: + assets: + - lib/src/assets/faqs/ +``` + +## Testing + +The package includes test support with: +- `bloc_test` for BLoC testing +- `mocktail` for mocking dependencies + +## Design System Integration + +Uses the common design system components: +- `UiColors` - Color constants +- `UiConstants` - Sizing and radius constants +- `LucideIcons` - Icon library + +## Notes + +- FAQs are cached in memory after first load to improve performance +- Search is case-insensitive +- The widget state (expanded/collapsed items) is local to the widget and resets on navigation diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json new file mode 100644 index 00000000..6b726e27 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/assets/faqs/faqs.json @@ -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." + } + ] + } +] diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart new file mode 100644 index 00000000..4bcc2ccd --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/data/repositories_impl/faqs_repository_impl.dart @@ -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? _cachedFaqs; + + @override + Future> 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 decoded = jsonDecode(faqsJson) as List; + + // Convert to domain entities + _cachedFaqs = decoded.map((dynamic item) { + final Map category = item as Map; + final String categoryName = category['category'] as String; + final List questionsData = + category['questions'] as List; + + final List questions = questionsData.map((dynamic q) { + final Map questionMap = q as Map; + 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 []; + } + } + + @override + Future> searchFaqs(String query) async { + try { + // Get all FAQs first + final List allFaqs = await getFaqs(); + + if (query.isEmpty) { + return allFaqs; + } + + final String lowerQuery = query.toLowerCase(); + + // Filter categories based on matching questions + final List filtered = allFaqs + .map((FaqCategory category) { + // Filter questions that match the query + final List 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() + .toList(); + + return filtered; + } catch (e) { + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart new file mode 100644 index 00000000..b199ea3b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_category.dart @@ -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 questions; + + const FaqCategory({ + required this.category, + required this.questions, + }); + + @override + List get props => [category, questions]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart new file mode 100644 index 00000000..c8bb86d8 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/entities/faq_item.dart @@ -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 get props => [question, answer]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart new file mode 100644 index 00000000..887ea0d1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/repositories/faqs_repository_interface.dart @@ -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> getFaqs(); + + /// Search FAQs by query string + /// Returns categories that contain matching questions + Future> searchFaqs(String query); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart new file mode 100644 index 00000000..c4da8f89 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/get_faqs_usecase.dart @@ -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> call() async { + try { + return await _repository.getFaqs(); + } catch (e) { + // Return empty list on error + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart new file mode 100644 index 00000000..39d36179 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/domain/usecases/search_faqs_usecase.dart @@ -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> call(SearchFaqsParams params) async { + try { + return await _repository.searchFaqs(params.query); + } catch (e) { + // Return empty list on error + return []; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart new file mode 100644 index 00000000..89c2291e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_bloc.dart @@ -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 { + final GetFaqsUseCase _getFaqsUseCase; + final SearchFaqsUseCase _searchFaqsUseCase; + + FaqsBloc({ + required GetFaqsUseCase getFaqsUseCase, + required SearchFaqsUseCase searchFaqsUseCase, + }) : _getFaqsUseCase = getFaqsUseCase, + _searchFaqsUseCase = searchFaqsUseCase, + super(const FaqsState()) { + on(_onFetchFaqs); + on(_onSearchFaqs); + } + + Future _onFetchFaqs( + FetchFaqsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, error: null)); + + try { + final List 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 _onSearchFaqs( + SearchFaqsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, error: null, searchQuery: event.query)); + + try { + final List 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', + ), + ); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart new file mode 100644 index 00000000..a853c239 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_event.dart @@ -0,0 +1,25 @@ +part of 'faqs_bloc.dart'; + +/// Base class for FAQs BLoC events +abstract class FaqsEvent extends Equatable { + const FaqsEvent(); + + @override + List get props => []; +} + +/// 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 get props => [query]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart new file mode 100644 index 00000000..29302c5f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/blocs/faqs_state.dart @@ -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 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 [], + this.isLoading = false, + this.searchQuery = '', + this.error, + }); + + /// Create a copy with optional field overrides + FaqsState copyWith({ + List? 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 get props => [ + categories, + isLoading, + searchQuery, + error, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart new file mode 100644 index 00000000..9c1b77a1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/pages/faqs_page.dart @@ -0,0 +1,95 @@ +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 'package:go_router/go_router.dart'; +import 'package:lucide_icons/lucide_icons.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 BlocProvider( + create: (BuildContext context) => Modular.get()..add(const FetchFaqsEvent()), + child: Scaffold( + backgroundColor: UiColors.background, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: GestureDetector( + onTap: () => context.pop(), + child: const Icon( + LucideIcons.chevronLeft, + color: UiColors.textSecondary, + ), + ), + title: Text( + t.staff_faqs.title, + style: const TextStyle( + color: UiColors.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border, height: 1), + ), + ), + body: Stack( + children: [ + const FaqsWidget(), + // Contact Support Button at Bottom + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SafeArea( + top: false, + child: SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: () => context.push('/messages'), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + elevation: 0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(LucideIcons.messageCircle, size: 20), + const SizedBox(width: 8), + Text( + t.staff_faqs.contact_support, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart new file mode 100644 index 00000000..317e607d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart @@ -0,0 +1,196 @@ +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:lucide_icons/lucide_icons.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 createState() => _FaqsWidgetState(); +} + +class _FaqsWidgetState extends State { + late TextEditingController _searchController; + final Map _openItems = {}; + + @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().add(const FetchFaqsEvent()); + } else { + context.read().add(SearchFaqsEvent(query: value)); + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, FaqsState state) { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 100), + child: Column( + children: [ + // 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( + LucideIcons.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: [ + const Icon( + LucideIcons.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 entry) { + final int catIndex = entry.key; + final dynamic categoryItem = entry.value; + final String categoryName = categoryItem.category; + final List questions = categoryItem.questions; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + categoryName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 12), + ...questions.asMap().entries.map((MapEntry 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: [ + InkWell( + onTap: () => _toggleItem(key), + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Text( + questionItem.question, + style: const TextStyle( + fontWeight: FontWeight.w500, + color: UiColors.textPrimary, + ), + ), + ), + Icon( + isOpen + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, + color: UiColors.textSecondary, + size: 20, + ), + ], + ), + ), + ), + if (isOpen) + Padding( + padding: const EdgeInsets.fromLTRB( + 16, + 0, + 16, + 16, + ), + child: Text( + questionItem.answer, + style: const TextStyle( + color: UiColors.textSecondary, + fontSize: 14, + height: 1.5, + ), + ), + ), + ], + ), + ); + }).toList(), + const SizedBox(height: 12), + ], + ); + }).toList(), + ], + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart new file mode 100644 index 00000000..f1a42142 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/staff_faqs_module.dart @@ -0,0 +1,51 @@ +import 'package:flutter_modular/flutter_modular.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( + () => FaqsRepositoryImpl(), + ); + + // Use Cases + i.addSingleton( + () => GetFaqsUseCase( + i(), + ), + ); + i.addSingleton( + () => SearchFaqsUseCase( + i(), + ), + ); + + // BLoC + i.add( + () => FaqsBloc( + getFaqsUseCase: i(), + searchFaqsUseCase: i(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child( + '/', + child: (_) => const FaqsPage(), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart new file mode 100644 index 00000000..46c3940d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/staff_faqs.dart @@ -0,0 +1,4 @@ +library staff_faqs; + +export 'src/staff_faqs_module.dart'; +export 'src/presentation/pages/faqs_page.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml new file mode 100644 index 00000000..7582e056 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/pubspec.yaml @@ -0,0 +1,37 @@ +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 + go_router: ^14.0.0 + lucide_icons: ^0.257.0 + + # Architecture Packages + krow_core: + path: ../../../../../core + design_system: + path: ../../../../../design_system + core_localization: + path: ../../../../../core_localization + +dev_dependencies: + flutter_test: + sdk: flutter + bloc_test: ^9.1.0 + mocktail: ^1.0.0 + +flutter: + uses-material-design: true + assets: + - lib/src/assets/faqs/ diff --git a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml index 44865ecf..f31d21a8 100644 --- a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml +++ b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml @@ -55,7 +55,9 @@ dependencies: staff_clock_in: path: ../clock_in staff_privacy_security: - path: ../profile_sections/settings/privacy_security + path: ../profile_sections/support/privacy_security + staff_faqs: + path: ../profile_sections/support/faqs dev_dependencies: flutter_test: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 5a9b3aaf..c5eaa978 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -573,6 +573,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: transitive + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" google_fonts: dependency: transitive description: @@ -1290,10 +1298,17 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: - path: "packages/features/staff/profile_sections/settings/privacy_security" + path: "packages/features/staff/profile_sections/support/privacy_security" relative: true source: path version: "0.0.1"