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!"
|
||||
}
|
||||
},
|
||||
"staff_faqs": {
|
||||
"title": "FAQs",
|
||||
"search_placeholder": "Search questions...",
|
||||
"no_results": "No matching questions found",
|
||||
"contact_support": "Contact Support"
|
||||
},
|
||||
"success": {
|
||||
"hub": {
|
||||
"created": "Hub created successfully!",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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:
|
||||
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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user