feat: Implement FAQs feature for staff application
- Created a modular package for Frequently Asked Questions (FAQs) functionality. - Established Clean Architecture with Domain, Data, and Presentation layers. - Implemented BLoC for state management with events and states. - Developed search functionality with real-time filtering of FAQs. - Designed an accordion UI for displaying FAQs by category. - Added localization support for English and Spanish. - Included comprehensive documentation and testing checklist. - Integrated dependency injection for repositories and use cases. - Configured routing for seamless navigation to FAQs page.
This commit is contained in:
@@ -1143,6 +1143,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!",
|
||||||
|
|||||||
@@ -1143,6 +1143,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!",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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<FaqsBloc>(
|
||||||
|
create: (BuildContext context) => Modular.get<FaqsBloc>()..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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
const Icon(LucideIcons.messageCircle, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
t.staff_faqs.contact_support,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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(
|
||||||
|
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: <Widget>[
|
||||||
|
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<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: 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(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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(
|
||||||
|
'/',
|
||||||
|
child: (_) => const FaqsPage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
library staff_faqs;
|
||||||
|
|
||||||
|
export 'src/staff_faqs_module.dart';
|
||||||
|
export 'src/presentation/pages/faqs_page.dart';
|
||||||
@@ -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/
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -573,6 +573,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
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:
|
google_fonts:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1290,10 +1298,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"
|
||||||
|
|||||||
Reference in New Issue
Block a user