feat: add benefit history feature with lazy loading and pagination

- Implemented `getBenefitsHistory` method in `HomeRepository` to retrieve paginated benefit history.
- Enhanced `BenefitsOverviewCubit` to manage loading and displaying benefit history.
- Created `BenefitHistoryPage` for full-screen display of benefit history with infinite scroll support.
- Added `BenefitHistoryPreview` widget for expandable history preview in benefit cards.
- Introduced `BenefitHistoryRow` to display individual history records.
- Updated `BenefitsOverviewState` to include history management fields.
- Added new entities and use cases for handling benefit history.
- Created design system documentation for UI patterns and known gaps.
This commit is contained in:
Achintha Isuru
2026-03-18 17:21:30 -04:00
parent 1552f60e5b
commit 9039aa63d6
22 changed files with 1047 additions and 19 deletions

View File

@@ -60,6 +60,20 @@ extension StaffNavigator on IModularNavigator {
safePush(StaffPaths.benefits);
}
/// Navigates to the full history page for a specific benefit.
void toBenefitHistory({
required String benefitId,
required String benefitTitle,
}) {
safePush(
StaffPaths.benefitHistory,
arguments: <String, dynamic>{
'benefitId': benefitId,
'benefitTitle': benefitTitle,
},
);
}
void toStaffMain() {
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
}

View File

@@ -75,6 +75,9 @@ class StaffPaths {
/// Benefits overview page.
static const String benefits = '/worker-main/home/benefits';
/// Benefit history page for a specific benefit.
static const String benefitHistory = '/worker-main/home/benefits/history';
/// Shifts tab - view and manage shifts.
///
/// Browse available shifts, accepted shifts, and shift history.

View File

@@ -672,7 +672,14 @@
"status": {
"pending": "Pending",
"submitted": "Submitted"
}
},
"history_header": "HISTORY",
"no_history": "No history yet",
"show_all": "Show all",
"hours_accrued": "+${hours}h accrued",
"hours_used": "-${hours}h used",
"history_page_title": "$benefit History",
"loading_more": "Loading..."
}
},
"auto_match": {

View File

@@ -667,7 +667,14 @@
"status": {
"pending": "Pendiente",
"submitted": "Enviado"
}
},
"history_header": "HISTORIAL",
"no_history": "Sin historial aún",
"show_all": "Ver todo",
"hours_accrued": "+${hours}h acumuladas",
"hours_used": "-${hours}h utilizadas",
"history_page_title": "Historial de $benefit",
"loading_more": "Cargando..."
}
},
"auto_match": {

View File

@@ -72,6 +72,7 @@ export 'src/entities/orders/recent_order.dart';
// Financial & Payroll
export 'src/entities/benefits/benefit.dart';
export 'src/entities/benefits/benefit_history.dart';
export 'src/entities/financial/invoice.dart';
export 'src/entities/financial/billing_account.dart';
export 'src/entities/financial/current_bill.dart';

View File

@@ -0,0 +1,100 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/src/entities/enums/benefit_status.dart';
/// A historical record of a staff benefit accrual period.
///
/// Returned by `GET /staff/profile/benefits/history`.
class BenefitHistory extends Equatable {
/// Creates a [BenefitHistory] instance.
const BenefitHistory({
required this.historyId,
required this.benefitId,
required this.benefitType,
required this.title,
required this.status,
required this.effectiveAt,
required this.trackedHours,
required this.targetHours,
this.endedAt,
this.notes,
});
/// Deserialises a [BenefitHistory] from a V2 API JSON map.
factory BenefitHistory.fromJson(Map<String, dynamic> json) {
return BenefitHistory(
historyId: json['historyId'] as String,
benefitId: json['benefitId'] as String,
benefitType: json['benefitType'] as String,
title: json['title'] as String,
status: BenefitStatus.fromJson(json['status'] as String?),
effectiveAt: DateTime.parse(json['effectiveAt'] as String),
endedAt: json['endedAt'] != null
? DateTime.parse(json['endedAt'] as String)
: null,
trackedHours: (json['trackedHours'] as num).toInt(),
targetHours: (json['targetHours'] as num).toInt(),
notes: json['notes'] as String?,
);
}
/// Unique identifier for this history record.
final String historyId;
/// The benefit this record belongs to.
final String benefitId;
/// Type code (e.g. SICK_LEAVE, VACATION).
final String benefitType;
/// Human-readable title.
final String title;
/// Status of the benefit during this period.
final BenefitStatus status;
/// When this benefit period became effective.
final DateTime effectiveAt;
/// When this benefit period ended, or `null` if still active.
final DateTime? endedAt;
/// Hours tracked during this period.
final int trackedHours;
/// Target hours for this period.
final int targetHours;
/// Optional notes about the accrual.
final String? notes;
/// Serialises this [BenefitHistory] to a JSON map.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'historyId': historyId,
'benefitId': benefitId,
'benefitType': benefitType,
'title': title,
'status': status.toJson(),
'effectiveAt': effectiveAt.toIso8601String(),
'endedAt': endedAt?.toIso8601String(),
'trackedHours': trackedHours,
'targetHours': targetHours,
'notes': notes,
};
}
@override
List<Object?> get props => <Object?>[
historyId,
benefitId,
benefitType,
title,
status,
effectiveAt,
endedAt,
trackedHours,
targetHours,
notes,
];
}

View File

@@ -80,7 +80,6 @@ class _ShiftDetails extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(shift.title, style: UiTypography.body2b),
// TODO: Ask BE to add clientName to the listTodayShifts response.
// Currently showing locationName as subtitle fallback.
Text(
shift.locationName ?? '',

View File

@@ -30,4 +30,24 @@ class HomeRepositoryImpl implements HomeRepository {
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
return completion.completed;
}
@override
Future<List<BenefitHistory>> getBenefitsHistory({
int limit = 20,
int offset = 0,
}) async {
final ApiResponse response = await _apiService.get(
StaffEndpoints.benefitsHistory,
params: <String, dynamic>{
'limit': limit,
'offset': offset,
},
);
final List<dynamic> items =
response.data['items'] as List<dynamic>? ?? <dynamic>[];
return items
.map((dynamic json) =>
BenefitHistory.fromJson(json as Map<String, dynamic>))
.toList();
}
}

View File

@@ -12,4 +12,10 @@ abstract class HomeRepository {
/// Retrieves whether the staff member's profile is complete.
Future<bool> getProfileCompletion();
/// Retrieves paginated benefit history for the staff member.
Future<List<BenefitHistory>> getBenefitsHistory({
int limit = 20,
int offset = 0,
});
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/domain/repositories/home_repository.dart';
/// Use case for fetching paginated benefit history for a staff member.
///
/// Delegates to [HomeRepository.getBenefitsHistory] and returns
/// a list of [BenefitHistory] records.
class GetBenefitsHistoryUseCase {
/// Creates a [GetBenefitsHistoryUseCase].
GetBenefitsHistoryUseCase(this._repository);
/// The repository used for data access.
final HomeRepository _repository;
/// Executes the use case to fetch benefit history.
Future<List<BenefitHistory>> call({int limit = 20, int offset = 0}) {
return _repository.getBenefitsHistory(limit: limit, offset: offset);
}
}

View File

@@ -3,22 +3,29 @@ import 'package:equatable/equatable.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/domain/repositories/home_repository.dart';
import 'package:staff_home/src/domain/usecases/get_benefits_history_usecase.dart';
part 'benefits_overview_state.dart';
/// Cubit managing the benefits overview page state.
///
/// Fetches the dashboard and extracts benefits for the detail page.
/// Fetches the dashboard benefits and lazily loads per-benefit history.
class BenefitsOverviewCubit extends Cubit<BenefitsOverviewState>
with BlocErrorHandler<BenefitsOverviewState> {
/// Creates a [BenefitsOverviewCubit].
BenefitsOverviewCubit({required HomeRepository repository})
: _repository = repository,
BenefitsOverviewCubit({
required HomeRepository repository,
required GetBenefitsHistoryUseCase getBenefitsHistory,
}) : _repository = repository,
_getBenefitsHistory = getBenefitsHistory,
super(const BenefitsOverviewState.initial());
/// The repository used for data access.
/// The repository used for dashboard data access.
final HomeRepository _repository;
/// Use case for fetching benefit history.
final GetBenefitsHistoryUseCase _getBenefitsHistory;
/// Loads benefits from the dashboard endpoint.
Future<void> loadBenefits() async {
if (isClosed) return;
@@ -44,4 +51,96 @@ class BenefitsOverviewCubit extends Cubit<BenefitsOverviewState>
},
);
}
/// Loads benefit history for a specific benefit (lazy, on first expand).
///
/// Skips if already loading or already loaded for the given [benefitId].
Future<void> loadBenefitHistory(String benefitId) async {
if (isClosed) return;
if (state.loadingHistoryIds.contains(benefitId)) return;
if (state.loadedHistoryIds.contains(benefitId)) return;
emit(state.copyWith(
loadingHistoryIds: <String>{...state.loadingHistoryIds, benefitId},
));
await handleError(
emit: emit,
action: () async {
final List<BenefitHistory> history =
await _getBenefitsHistory(limit: 20, offset: 0);
if (isClosed) return;
final List<BenefitHistory> filtered = history
.where((BenefitHistory h) => h.benefitId == benefitId)
.toList();
emit(state.copyWith(
historyByBenefitId: <String, List<BenefitHistory>>{
...state.historyByBenefitId,
benefitId: filtered,
},
loadingHistoryIds: <String>{...state.loadingHistoryIds}
..remove(benefitId),
loadedHistoryIds: <String>{...state.loadedHistoryIds, benefitId},
hasMoreHistory: <String, bool>{
...state.hasMoreHistory,
benefitId: history.length >= 20,
},
));
},
onError: (String errorKey) {
if (isClosed) return state;
return state.copyWith(
loadingHistoryIds: <String>{...state.loadingHistoryIds}
..remove(benefitId),
);
},
);
}
/// Loads more history for infinite scroll on the full history page.
///
/// Appends results to existing history for the given [benefitId].
Future<void> loadMoreBenefitHistory(String benefitId) async {
if (isClosed) return;
if (state.loadingHistoryIds.contains(benefitId)) return;
if (!(state.hasMoreHistory[benefitId] ?? true)) return;
final List<BenefitHistory> existing =
state.historyByBenefitId[benefitId] ?? <BenefitHistory>[];
emit(state.copyWith(
loadingHistoryIds: <String>{...state.loadingHistoryIds, benefitId},
));
await handleError(
emit: emit,
action: () async {
final List<BenefitHistory> history =
await _getBenefitsHistory(limit: 20, offset: existing.length);
if (isClosed) return;
final List<BenefitHistory> filtered = history
.where((BenefitHistory h) => h.benefitId == benefitId)
.toList();
emit(state.copyWith(
historyByBenefitId: <String, List<BenefitHistory>>{
...state.historyByBenefitId,
benefitId: <BenefitHistory>[...existing, ...filtered],
},
loadingHistoryIds: <String>{...state.loadingHistoryIds}
..remove(benefitId),
hasMoreHistory: <String, bool>{
...state.hasMoreHistory,
benefitId: history.length >= 20,
},
));
},
onError: (String errorKey) {
if (isClosed) return state;
return state.copyWith(
loadingHistoryIds: <String>{...state.loadingHistoryIds}
..remove(benefitId),
);
},
);
}
}

View File

@@ -1,33 +1,78 @@
part of 'benefits_overview_cubit.dart';
/// Status of the benefits overview data fetch.
enum BenefitsOverviewStatus { initial, loading, loaded, error }
/// State for [BenefitsOverviewCubit].
///
/// Holds both the top-level benefits list and per-benefit history data
/// used by [BenefitHistoryPreview] and [BenefitHistoryPage].
class BenefitsOverviewState extends Equatable {
final BenefitsOverviewStatus status;
final List<Benefit> benefits;
final String? errorMessage;
/// Creates a [BenefitsOverviewState].
const BenefitsOverviewState({
required this.status,
this.benefits = const [],
this.benefits = const <Benefit>[],
this.errorMessage,
this.historyByBenefitId = const <String, List<BenefitHistory>>{},
this.loadingHistoryIds = const <String>{},
this.loadedHistoryIds = const <String>{},
this.hasMoreHistory = const <String, bool>{},
});
/// Initial state with no data.
const BenefitsOverviewState.initial()
: this(status: BenefitsOverviewStatus.initial);
/// Current status of the top-level benefits fetch.
final BenefitsOverviewStatus status;
/// The list of staff benefits.
final List<Benefit> benefits;
/// Error message when [status] is [BenefitsOverviewStatus.error].
final String? errorMessage;
/// Cached history records keyed by benefit ID.
final Map<String, List<BenefitHistory>> historyByBenefitId;
/// Benefit IDs currently loading history.
final Set<String> loadingHistoryIds;
/// Benefit IDs whose history has been loaded at least once.
final Set<String> loadedHistoryIds;
/// Whether more pages of history are available per benefit.
final Map<String, bool> hasMoreHistory;
/// Creates a copy with the given fields replaced.
BenefitsOverviewState copyWith({
BenefitsOverviewStatus? status,
List<Benefit>? benefits,
String? errorMessage,
Map<String, List<BenefitHistory>>? historyByBenefitId,
Set<String>? loadingHistoryIds,
Set<String>? loadedHistoryIds,
Map<String, bool>? hasMoreHistory,
}) {
return BenefitsOverviewState(
status: status ?? this.status,
benefits: benefits ?? this.benefits,
errorMessage: errorMessage ?? this.errorMessage,
historyByBenefitId: historyByBenefitId ?? this.historyByBenefitId,
loadingHistoryIds: loadingHistoryIds ?? this.loadingHistoryIds,
loadedHistoryIds: loadedHistoryIds ?? this.loadedHistoryIds,
hasMoreHistory: hasMoreHistory ?? this.hasMoreHistory,
);
}
@override
List<Object?> get props => [status, benefits, errorMessage];
List<Object?> get props => <Object?>[
status,
benefits,
errorMessage,
historyByBenefitId,
loadingHistoryIds,
loadedHistoryIds,
hasMoreHistory,
];
}

View File

@@ -0,0 +1,162 @@
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:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_row.dart';
/// Full-screen page displaying paginated benefit history.
///
/// Supports infinite scroll via [ScrollController] and
/// [BenefitsOverviewCubit.loadMoreBenefitHistory].
class BenefitHistoryPage extends StatefulWidget {
/// Creates a [BenefitHistoryPage].
const BenefitHistoryPage({
required this.benefitId,
required this.benefitTitle,
super.key,
});
/// The ID of the benefit whose history to display.
final String benefitId;
/// The human-readable benefit title shown in the app bar.
final String benefitTitle;
@override
State<BenefitHistoryPage> createState() => _BenefitHistoryPageState();
}
class _BenefitHistoryPageState extends State<BenefitHistoryPage> {
/// Scroll controller for infinite scroll detection.
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
final BenefitsOverviewCubit cubit =
Modular.get<BenefitsOverviewCubit>();
if (!cubit.state.loadedHistoryIds.contains(widget.benefitId)) {
cubit.loadBenefitHistory(widget.benefitId);
}
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final dynamic i18n = t.staff.home.benefits.overview;
final String pageTitle =
i18n.history_page_title(benefit: widget.benefitTitle) as String;
return Scaffold(
appBar: UiAppBar(
title: pageTitle,
showBackButton: true,
),
body: BlocProvider.value(
value: Modular.get<BenefitsOverviewCubit>(),
child: BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
buildWhen: (BenefitsOverviewState previous,
BenefitsOverviewState current) =>
previous.historyByBenefitId[widget.benefitId] !=
current.historyByBenefitId[widget.benefitId] ||
previous.loadingHistoryIds != current.loadingHistoryIds ||
previous.loadedHistoryIds != current.loadedHistoryIds,
builder: (BuildContext context, BenefitsOverviewState state) {
final bool isLoading =
state.loadingHistoryIds.contains(widget.benefitId);
final bool isLoaded =
state.loadedHistoryIds.contains(widget.benefitId);
final List<BenefitHistory> history =
state.historyByBenefitId[widget.benefitId] ?? <BenefitHistory>[];
final bool hasMore = state.hasMoreHistory[widget.benefitId] ?? true;
// Initial loading state
if (isLoading && !isLoaded) {
return _buildLoadingSkeleton();
}
// Empty state
if (isLoaded && history.isEmpty) {
return UiEmptyState(
icon: UiIcons.clock,
title: i18n.no_history as String,
description: '',
);
}
// Loaded list with infinite scroll
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space4,
),
itemCount: history.length + (hasMore ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index >= history.length) {
// Bottom loading indicator
return isLoading
? const Padding(
padding: EdgeInsets.all(UiConstants.space4),
child: Center(child: CircularProgressIndicator()),
)
: const SizedBox.shrink();
}
return BenefitHistoryRow(history: history[index]);
},
);
},
),
),
);
}
/// Triggers loading more history when scrolled near the bottom.
void _onScroll() {
if (!_scrollController.hasClients) return;
final double maxScroll = _scrollController.position.maxScrollExtent;
final double currentScroll = _scrollController.offset;
if (maxScroll - currentScroll <= 200) {
final BenefitsOverviewCubit cubit =
ReadContext(context).read<BenefitsOverviewCubit>();
cubit.loadMoreBenefitHistory(widget.benefitId);
}
}
/// Builds a shimmer skeleton for the initial loading state.
Widget _buildLoadingSkeleton() {
return UiShimmer(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
children: <Widget>[
for (int i = 0; i < 8; i++)
Padding(
padding:
const EdgeInsets.symmetric(vertical: UiConstants.space2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
UiShimmerLine(width: 100, height: 14),
UiShimmerLine(width: 80, height: 14),
],
),
),
],
),
),
);
}
}

View File

@@ -19,9 +19,8 @@ class BenefitsOverviewPage extends StatelessWidget {
subtitle: t.staff.home.benefits.overview.subtitle,
showBackButton: true,
),
body: BlocProvider<BenefitsOverviewCubit>(
create: (context) =>
Modular.get<BenefitsOverviewCubit>()..loadBenefits(),
body: BlocProvider<BenefitsOverviewCubit>.value(
value: Modular.get<BenefitsOverviewCubit>()..loadBenefits(),
child: BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
builder: (context, state) {
if (state.status == BenefitsOverviewStatus.loading ||

View File

@@ -2,8 +2,9 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_card_header.dart';
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_preview.dart';
/// Card widget displaying detailed benefit information.
/// Card widget displaying detailed benefit information with history preview.
class BenefitCard extends StatelessWidget {
/// Creates a [BenefitCard].
const BenefitCard({required this.benefit, super.key});
@@ -24,6 +25,11 @@ class BenefitCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
BenefitCardHeader(benefit: benefit),
const SizedBox(height: UiConstants.space4),
BenefitHistoryPreview(
benefitId: benefit.benefitId,
benefitTitle: benefit.title,
),
],
),
);

View File

@@ -0,0 +1,178 @@
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:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_history_row.dart';
/// Expandable preview section showing recent benefit history on a card.
///
/// Collapses by default. On first expand, triggers a lazy load of history
/// for the given [benefitId] via [BenefitsOverviewCubit.loadBenefitHistory].
/// Shows the first 5 records and a "Show all" button when more exist.
class BenefitHistoryPreview extends StatefulWidget {
/// Creates a [BenefitHistoryPreview].
const BenefitHistoryPreview({
required this.benefitId,
required this.benefitTitle,
super.key,
});
/// The ID of the benefit whose history to display.
final String benefitId;
/// The human-readable benefit title, passed to the full history page.
final String benefitTitle;
@override
State<BenefitHistoryPreview> createState() => _BenefitHistoryPreviewState();
}
class _BenefitHistoryPreviewState extends State<BenefitHistoryPreview> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final dynamic i18n = t.staff.home.benefits.overview;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Divider(height: 1, color: UiColors.border),
InkWell(
onTap: _toggleExpanded,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
i18n.history_header as String,
style: UiTypography.footnote2b.textSecondary,
),
Icon(
_isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
size: UiConstants.iconSm,
color: UiColors.iconSecondary,
),
],
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: _isExpanded ? _buildContent(i18n) : const SizedBox.shrink(),
),
],
);
}
/// Toggles expansion and triggers history load on first expand.
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
});
if (_isExpanded) {
final BenefitsOverviewCubit cubit =
ReadContext(context).read<BenefitsOverviewCubit>();
cubit.loadBenefitHistory(widget.benefitId);
}
}
/// Builds the expanded content section.
Widget _buildContent(dynamic i18n) {
return BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
buildWhen: (BenefitsOverviewState previous,
BenefitsOverviewState current) =>
previous.historyByBenefitId[widget.benefitId] !=
current.historyByBenefitId[widget.benefitId] ||
previous.loadingHistoryIds != current.loadingHistoryIds ||
previous.loadedHistoryIds != current.loadedHistoryIds,
builder: (BuildContext context, BenefitsOverviewState state) {
final bool isLoading =
state.loadingHistoryIds.contains(widget.benefitId);
final bool isLoaded =
state.loadedHistoryIds.contains(widget.benefitId);
final List<BenefitHistory> history =
state.historyByBenefitId[widget.benefitId] ?? <BenefitHistory>[];
if (isLoading && !isLoaded) {
return _buildShimmer();
}
if (isLoaded && history.isEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Text(
i18n.no_history as String,
style: UiTypography.body3r.textSecondary,
),
);
}
final int previewCount = history.length > 5 ? 5 : history.length;
final bool showAll = history.length > 5;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
for (int i = 0; i < previewCount; i++)
BenefitHistoryRow(history: history[i]),
if (!showAll)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _navigateToFullHistory,
child: Text(
i18n.show_all as String,
style: UiTypography.footnote1m.copyWith(
color: UiColors.primary,
),
),
),
),
const SizedBox(height: UiConstants.space1),
],
);
},
);
}
/// Builds shimmer placeholder rows while loading.
Widget _buildShimmer() {
return UiShimmer(
child: Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Column(
children: <Widget>[
for (int i = 0; i < 3; i++)
Padding(
padding:
const EdgeInsets.symmetric(vertical: UiConstants.space2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
UiShimmerLine(width: 100, height: 12),
UiShimmerLine(width: 60, height: 12),
],
),
),
],
),
),
);
}
/// Navigates to the full benefit history page.
void _navigateToFullHistory() {
Modular.to.toBenefitHistory(
benefitId: widget.benefitId,
benefitTitle: widget.benefitTitle,
);
}
}

View File

@@ -0,0 +1,126 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// A single row displaying one [BenefitHistory] record.
///
/// Shows the effective date, optional notes, accrued hours badge, and a
/// status chip. Used in both [BenefitHistoryPreview] and [BenefitHistoryPage].
class BenefitHistoryRow extends StatelessWidget {
/// Creates a [BenefitHistoryRow].
const BenefitHistoryRow({required this.history, super.key});
/// The history record to display.
final BenefitHistory history;
@override
Widget build(BuildContext context) {
final dynamic i18n = t.staff.home.benefits.overview;
return Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
child: Row(
children: <Widget>[
// Date column
Text(
DateFormat('d MMM, yyyy').format(history.effectiveAt),
style: UiTypography.footnote1r.textSecondary,
),
const SizedBox(width: UiConstants.space3),
// Notes (takes remaining space)
Expanded(
child: history.notes != null && history.notes!.isNotEmpty
? Text(
history.notes!,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
maxLines: 1,
)
: const SizedBox.shrink(),
),
const SizedBox(width: UiConstants.space2),
// Hours badge
_buildHoursBadge(i18n),
const SizedBox(width: UiConstants.space2),
// Status chip
_buildStatusChip(i18n),
],
),
);
}
/// Builds the hours badge showing tracked hours.
Widget _buildHoursBadge(dynamic i18n) {
final String label = '+${history.trackedHours}h';
return Text(
label,
style: UiTypography.footnote2b.copyWith(color: UiColors.textSuccess),
);
}
/// Builds a chip indicating the benefit history status.
Widget _buildStatusChip(dynamic i18n) {
final _StatusStyle statusStyle = _resolveStatusStyle(history.status);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: statusStyle.backgroundColor,
borderRadius: UiConstants.radiusFull,
border: Border.all(color: statusStyle.borderColor, width: 0.5),
),
child: Text(
statusStyle.label,
style: UiTypography.footnote2m.copyWith(color: statusStyle.textColor),
),
);
}
/// Maps a [BenefitStatus] to display style values.
_StatusStyle _resolveStatusStyle(BenefitStatus status) {
final dynamic i18n = t.staff.home.benefits.overview.status;
switch (status) {
case BenefitStatus.active:
return _StatusStyle(
label: i18n.submitted,
backgroundColor: UiColors.tagSuccess,
textColor: UiColors.textSuccess,
borderColor: UiColors.tagSuccess,
);
case BenefitStatus.pending:
return _StatusStyle(
label: i18n.pending,
backgroundColor: UiColors.tagPending,
textColor: UiColors.mutedForeground,
borderColor: UiColors.border,
);
case BenefitStatus.inactive:
case BenefitStatus.unknown:
return _StatusStyle(
label: i18n.pending,
backgroundColor: UiColors.muted,
textColor: UiColors.mutedForeground,
borderColor: UiColors.border,
);
}
}
}
/// Internal value type for status chip styling.
class _StatusStyle {
const _StatusStyle({
required this.label,
required this.backgroundColor,
required this.textColor,
required this.borderColor,
});
final String label;
final Color backgroundColor;
final Color textColor;
final Color borderColor;
}

View File

@@ -4,9 +4,11 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_home/src/data/repositories/home_repository_impl.dart';
import 'package:staff_home/src/domain/repositories/home_repository.dart';
import 'package:staff_home/src/domain/usecases/get_benefits_history_usecase.dart';
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
import 'package:staff_home/src/presentation/pages/benefit_history_page.dart';
import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart';
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
@@ -33,6 +35,9 @@ class StaffHomeModule extends Module {
i.addLazySingleton<GetProfileCompletionUseCase>(
() => GetProfileCompletionUseCase(i.get<HomeRepository>()),
);
i.addLazySingleton<GetBenefitsHistoryUseCase>(
() => GetBenefitsHistoryUseCase(i.get<HomeRepository>()),
);
// Presentation layer - Cubits
i.addLazySingleton<HomeCubit>(
@@ -42,9 +47,12 @@ class StaffHomeModule extends Module {
),
);
// Cubit for benefits overview page
// Cubit for benefits overview page (includes history support)
i.addLazySingleton<BenefitsOverviewCubit>(
() => BenefitsOverviewCubit(repository: i.get<HomeRepository>()),
() => BenefitsOverviewCubit(
repository: i.get<HomeRepository>(),
getBenefitsHistory: i.get<GetBenefitsHistoryUseCase>(),
),
);
}
@@ -58,5 +66,16 @@ class StaffHomeModule extends Module {
StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefits),
child: (BuildContext context) => const BenefitsOverviewPage(),
);
r.child(
StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefitHistory),
child: (BuildContext context) {
final Map<String, dynamic>? args =
r.args.data as Map<String, dynamic>?;
return BenefitHistoryPage(
benefitId: args?['benefitId'] as String? ?? '',
benefitTitle: args?['benefitTitle'] as String? ?? '',
);
},
);
}
}