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:
7
.claude/agent-memory/ui-ux-design/MEMORY.md
Normal file
7
.claude/agent-memory/ui-ux-design/MEMORY.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# UI/UX Design Agent Memory
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
- [design-system-tokens.md](design-system-tokens.md) — Verified token values from actual source files
|
||||||
|
- [component-patterns.md](component-patterns.md) — Established component patterns in KROW staff app
|
||||||
|
- [design-gaps.md](design-gaps.md) — Known design system gaps and escalation items
|
||||||
87
.claude/agent-memory/ui-ux-design/component-patterns.md
Normal file
87
.claude/agent-memory/ui-ux-design/component-patterns.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: KROW Staff App Component Patterns
|
||||||
|
description: Established UI patterns, widget conventions, and design decisions confirmed in the KROW staff app codebase
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card Pattern (standard surface)
|
||||||
|
|
||||||
|
Cards use:
|
||||||
|
- `UiColors.cardViewBackground` (white) background
|
||||||
|
- `Border.all(color: UiColors.border)` outline
|
||||||
|
- `BorderRadius.circular(UiConstants.radiusBase)` = 12dp
|
||||||
|
- `EdgeInsets.all(UiConstants.space4)` = 16dp padding
|
||||||
|
|
||||||
|
Do NOT use `UiColors.bgSecondary` as card background — that is for toggles/headers inside cards.
|
||||||
|
|
||||||
|
## Section Toggle / Expand-Collapse Header
|
||||||
|
|
||||||
|
Used for collapsible sections inside cards:
|
||||||
|
- Background: `UiColors.bgSecondary`
|
||||||
|
- Radius: `UiConstants.radiusMd` (6dp)
|
||||||
|
- Height: minimum 48dp (touch target)
|
||||||
|
- Label: `UiTypography.titleUppercase3m.textSecondary` for ALL-CAPS labels
|
||||||
|
- Trailing: `UiIcons.chevronDown` animated 180° via `AnimatedRotation`, 200ms
|
||||||
|
- Ripple: `InkWell` with `borderRadius: UiConstants.radiusMd` and splash `UiColors.primary.withValues(alpha: 0.06)`
|
||||||
|
|
||||||
|
## Shimmer Loading Pattern
|
||||||
|
|
||||||
|
Use `UiShimmer` wrapper + `UiShimmerLine` / `UiShimmerBox` / `UiShimmerCircle` primitives.
|
||||||
|
- Base color: `UiColors.muted`
|
||||||
|
- Highlight: `UiColors.background`
|
||||||
|
- For list content: 3 shimmer rows by default
|
||||||
|
- Do NOT use fixed height containers for shimmer — let content flow
|
||||||
|
|
||||||
|
## Status Badge (read-only, non-interactive)
|
||||||
|
|
||||||
|
Custom `Container` with pill shape:
|
||||||
|
- `borderRadius: UiConstants.radiusFull`
|
||||||
|
- `padding: EdgeInsets.symmetric(horizontal: space2, vertical: 2)`
|
||||||
|
- Label style: `UiTypography.footnote2b`
|
||||||
|
- Do NOT use the interactive `UiChip` widget for read-only display
|
||||||
|
|
||||||
|
Status color mapping:
|
||||||
|
- ACTIVE: bg=`tagActive`, fg=`textSuccess`
|
||||||
|
- PENDING: bg=`tagPending`, fg=`textWarning`
|
||||||
|
- INACTIVE/ENDED: bg=`tagFreeze`, fg=`textSecondary`
|
||||||
|
- ERROR: bg=`tagError`, fg=`textError`
|
||||||
|
|
||||||
|
## Inline Error Banner (inside card)
|
||||||
|
|
||||||
|
NOT a full-page error — a compact container inside the widget:
|
||||||
|
- bg: `UiColors.tagError`
|
||||||
|
- radius: `UiConstants.radiusMd`
|
||||||
|
- Icon: `UiIcons.error` at `iconMd` (20dp), color: `UiColors.destructive`
|
||||||
|
- Title: `body2m.textError`
|
||||||
|
- Retry link: `body3r.primary` with `TextDecoration.underline`
|
||||||
|
|
||||||
|
## Inline Empty State (inside card)
|
||||||
|
|
||||||
|
NOT `UiEmptyState` widget (that is full-page). Use compact inline version:
|
||||||
|
- `Icon(UiIcons.clock, size: iconXl=32, color: UiColors.iconDisabled)`
|
||||||
|
- `body2r.textSecondary` label
|
||||||
|
- `EdgeInsets.symmetric(vertical: space6)` padding
|
||||||
|
|
||||||
|
## AnimatedSize for Expand/Collapse
|
||||||
|
|
||||||
|
```dart
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
child: isExpanded ? content : const SizedBox.shrink(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits Feature Structure
|
||||||
|
|
||||||
|
Legacy benefits: `apps/mobile/legacy/legacy-staff-app/lib/features/profile/benefits/`
|
||||||
|
V2 domain entity: `apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart`
|
||||||
|
V2 history entity: needs creation at `packages/domain/lib/src/entities/benefits/benefit_history.dart`
|
||||||
|
|
||||||
|
Benefit history is lazy-loaded per card (not with the initial overview fetch).
|
||||||
|
History state is cached in BLoC as `Map<String, AsyncValue<List<BenefitHistory>>>` keyed by benefitId.
|
||||||
|
|
||||||
|
## Screen Page Pattern (overview pages)
|
||||||
|
|
||||||
|
Uses `CustomScrollView` with `SliverList` for header + `SliverPadding` wrapping `SliverList.separated` for content.
|
||||||
|
Bottom padding on content sliver: `EdgeInsets.fromLTRB(16, 16, 16, 120)` to clear bottom nav bar.
|
||||||
22
.claude/agent-memory/ui-ux-design/design-gaps.md
Normal file
22
.claude/agent-memory/ui-ux-design/design-gaps.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: KROW Design System Gaps and Escalations
|
||||||
|
description: Known missing tokens, open design questions, and items requiring escalation to PM or design system owner
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Escalations (as of 2026-03-18)
|
||||||
|
|
||||||
|
### 1. No Dark Theme Token Definitions
|
||||||
|
**Severity:** High
|
||||||
|
**Detail:** `ui_colors.dart` defines a single light `ColorScheme`. Tag colors (`tagActive`, `tagPending`, `tagFreeze`, `tagError`) have no dark mode equivalents. No dark theme has been configured in `UiTheme`.
|
||||||
|
**Action:** Escalate to design system owner before any dark mode work. Until resolved, do not attempt dark mode overrides in feature widgets.
|
||||||
|
|
||||||
|
### 2. V2 History API — trackedHours Sign Convention
|
||||||
|
**Severity:** Medium
|
||||||
|
**Detail:** `GET /staff/profile/benefits/history` returns `trackedHours` as a positive integer. There is no `transactionType` field to distinguish accruals from deductions (used hours). Design assumes accrual-only for now with `+` prefix in `UiColors.textSuccess`.
|
||||||
|
**Action:** Escalate to PM/backend. Recommend adding `transactionType: "ACCRUAL" | "USAGE"` or signed integer to distinguish visually.
|
||||||
|
|
||||||
|
### 3. Missing Localization Keys for Benefits History
|
||||||
|
**Severity:** Low (implementation blocker, not design blocker)
|
||||||
|
**Detail:** New keys under `benefits.history.*` need to be added to both `en.i18n.json` and `es.i18n.json` in `packages/core_localization/lib/src/l10n/`. Must be coordinated with Mobile Feature Agent who runs `dart run slang`.
|
||||||
|
**Action:** Hand off key list to Mobile Feature Agent.
|
||||||
102
.claude/agent-memory/ui-ux-design/design-system-tokens.md
Normal file
102
.claude/agent-memory/ui-ux-design/design-system-tokens.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
name: KROW Design System Token Reference
|
||||||
|
description: Verified token values from actual source files in apps/mobile/packages/design_system/lib/src/
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Files (verified 2026-03-18)
|
||||||
|
|
||||||
|
- `ui_colors.dart` — all color tokens
|
||||||
|
- `ui_typography.dart` — all text styles (primary font: Instrument Sans, secondary: Space Grotesk)
|
||||||
|
- `ui_constants.dart` — spacing, radius, icon sizes
|
||||||
|
- `ui_icons.dart` — icon aliases over LucideIcons (primary) + FontAwesomeIcons (secondary)
|
||||||
|
|
||||||
|
## Key Color Tokens (hex values confirmed)
|
||||||
|
|
||||||
|
| Token | Hex | Use |
|
||||||
|
|-------|-----|-----|
|
||||||
|
| `UiColors.background` | `#FAFBFC` | Page background |
|
||||||
|
| `UiColors.cardViewBackground` | `#FFFFFF` | Card surface |
|
||||||
|
| `UiColors.bgSecondary` | `#F1F3F5` | Toggle/section headers |
|
||||||
|
| `UiColors.bgThird` | `#EDF0F2` | — |
|
||||||
|
| `UiColors.primary` | `#0A39DF` | Brand blue |
|
||||||
|
| `UiColors.textPrimary` | `#121826` | Main text |
|
||||||
|
| `UiColors.textSecondary` | `#6A7382` | Secondary/muted text |
|
||||||
|
| `UiColors.textInactive` | `#9CA3AF` | Disabled/placeholder |
|
||||||
|
| `UiColors.textSuccess` | `#0A8159` | Green text (darker than success icon) |
|
||||||
|
| `UiColors.textError` | `#F04444` | Red text |
|
||||||
|
| `UiColors.textWarning` | `#D97706` | Amber text |
|
||||||
|
| `UiColors.success` | `#10B981` | Green brand color |
|
||||||
|
| `UiColors.destructive` | `#F04444` | Red brand color |
|
||||||
|
| `UiColors.border` | `#D1D5DB` | Default border |
|
||||||
|
| `UiColors.separatorSecondary` | `#F1F5F9` | Light dividers |
|
||||||
|
| `UiColors.tagActive` | `#DCFCE7` | Active status badge bg |
|
||||||
|
| `UiColors.tagPending` | `#FEF3C7` | Pending badge bg |
|
||||||
|
| `UiColors.tagError` | `#FEE2E2` | Error banner bg |
|
||||||
|
| `UiColors.tagFreeze` | `#F3F4F6` | Ended/frozen badge bg |
|
||||||
|
| `UiColors.tagInProgress` | `#DBEAFE` | In-progress badge bg |
|
||||||
|
| `UiColors.iconDisabled` | `#D1D5DB` | Disabled icon color |
|
||||||
|
| `UiColors.muted` | `#F1F3F5` | Shimmer base color |
|
||||||
|
|
||||||
|
## Key Spacing Constants
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `space1` | 4dp |
|
||||||
|
| `space2` | 8dp |
|
||||||
|
| `space3` | 12dp |
|
||||||
|
| `space4` | 16dp |
|
||||||
|
| `space5` | 20dp |
|
||||||
|
| `space6` | 24dp |
|
||||||
|
| `space8` | 32dp |
|
||||||
|
| `space10` | 40dp |
|
||||||
|
| `space12` | 48dp |
|
||||||
|
|
||||||
|
## Key Radius Constants
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `radiusSm` | 4dp |
|
||||||
|
| `radiusMd` (radiusMdValue) | 6dp |
|
||||||
|
| `radiusBase` | 12dp |
|
||||||
|
| `radiusLg` | 12dp (BorderRadius.circular(12)) |
|
||||||
|
| `radiusXl` | 16dp |
|
||||||
|
| `radius2xl` | 24dp |
|
||||||
|
| `radiusFull` | 999dp |
|
||||||
|
|
||||||
|
NOTE: `radiusBase` is a `double` (12.0), `radiusLg` is a `BorderRadius`. Use `BorderRadius.circular(UiConstants.radiusBase)` when a double is needed.
|
||||||
|
|
||||||
|
## Icon Sizes
|
||||||
|
|
||||||
|
| Token | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| `iconXs` | 12dp |
|
||||||
|
| `iconSm` | 16dp |
|
||||||
|
| `iconMd` | 20dp |
|
||||||
|
| `iconLg` | 24dp |
|
||||||
|
| `iconXl` | 32dp |
|
||||||
|
|
||||||
|
## Key Typography Styles (Instrument Sans)
|
||||||
|
|
||||||
|
| Token | Size | Weight | Notes |
|
||||||
|
|-------|------|--------|-------|
|
||||||
|
| `display1b` | 26px | 600 | letterSpacing: -1 |
|
||||||
|
| `title1b` | 18px | 600 | height: 1.5 |
|
||||||
|
| `title1m` | 18px | 500 | height: 1.5 |
|
||||||
|
| `title2b` | 16px | 600 | height: 1.1 |
|
||||||
|
| `body1m` | 16px | 600 | letterSpacing: -0.025 |
|
||||||
|
| `body1r` | 16px | 400 | letterSpacing: -0.05 |
|
||||||
|
| `body2b` | 14px | 700 | height: 1.5 |
|
||||||
|
| `body2m` | 14px | 500 | height: 1.5 |
|
||||||
|
| `body2r` | 14px | 400 | letterSpacing: 0.1 |
|
||||||
|
| `body3r` | 12px | 400 | height: 1.5 |
|
||||||
|
| `body3m` | 12px | 500 | letterSpacing: -0.1 |
|
||||||
|
| `footnote1r` | 12px | 400 | letterSpacing: 0.05 |
|
||||||
|
| `footnote1m` | 12px | 500 | — |
|
||||||
|
| `footnote2b` | 10px | 700 | — |
|
||||||
|
| `footnote2r` | 10px | 400 | — |
|
||||||
|
| `titleUppercase3m` | 12px | 500 | letterSpacing: 0.7 — use for ALL-CAPS section labels |
|
||||||
|
|
||||||
|
## Typography Color Extension
|
||||||
|
|
||||||
|
`UiTypography` styles have a `.textSecondary`, `.textSuccess`, `.textError`, `.textWarning`, `.primary`, `.white` extension defined in `TypographyColors`. Use these instead of `.copyWith(color: ...)` where possible for brevity.
|
||||||
@@ -60,6 +60,20 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
safePush(StaffPaths.benefits);
|
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() {
|
void toStaffMain() {
|
||||||
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
safePushNamedAndRemoveUntil('${StaffPaths.main}/home/', (_) => false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ class StaffPaths {
|
|||||||
/// Benefits overview page.
|
/// Benefits overview page.
|
||||||
static const String benefits = '/worker-main/home/benefits';
|
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.
|
/// Shifts tab - view and manage shifts.
|
||||||
///
|
///
|
||||||
/// Browse available shifts, accepted shifts, and shift history.
|
/// Browse available shifts, accepted shifts, and shift history.
|
||||||
|
|||||||
@@ -672,7 +672,14 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"submitted": "Submitted"
|
"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": {
|
"auto_match": {
|
||||||
|
|||||||
@@ -667,7 +667,14 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"pending": "Pendiente",
|
"pending": "Pendiente",
|
||||||
"submitted": "Enviado"
|
"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": {
|
"auto_match": {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export 'src/entities/orders/recent_order.dart';
|
|||||||
|
|
||||||
// Financial & Payroll
|
// Financial & Payroll
|
||||||
export 'src/entities/benefits/benefit.dart';
|
export 'src/entities/benefits/benefit.dart';
|
||||||
|
export 'src/entities/benefits/benefit_history.dart';
|
||||||
export 'src/entities/financial/invoice.dart';
|
export 'src/entities/financial/invoice.dart';
|
||||||
export 'src/entities/financial/billing_account.dart';
|
export 'src/entities/financial/billing_account.dart';
|
||||||
export 'src/entities/financial/current_bill.dart';
|
export 'src/entities/financial/current_bill.dart';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -80,7 +80,6 @@ class _ShiftDetails extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(shift.title, style: UiTypography.body2b),
|
Text(shift.title, style: UiTypography.body2b),
|
||||||
// TODO: Ask BE to add clientName to the listTodayShifts response.
|
|
||||||
// Currently showing locationName as subtitle fallback.
|
// Currently showing locationName as subtitle fallback.
|
||||||
Text(
|
Text(
|
||||||
shift.locationName ?? '',
|
shift.locationName ?? '',
|
||||||
|
|||||||
@@ -30,4 +30,24 @@ class HomeRepositoryImpl implements HomeRepository {
|
|||||||
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
|
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
|
||||||
return completion.completed;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,10 @@ abstract class HomeRepository {
|
|||||||
|
|
||||||
/// Retrieves whether the staff member's profile is complete.
|
/// Retrieves whether the staff member's profile is complete.
|
||||||
Future<bool> getProfileCompletion();
|
Future<bool> getProfileCompletion();
|
||||||
|
|
||||||
|
/// Retrieves paginated benefit history for the staff member.
|
||||||
|
Future<List<BenefitHistory>> getBenefitsHistory({
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,29 @@ import 'package:equatable/equatable.dart';
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:staff_home/src/domain/repositories/home_repository.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';
|
part 'benefits_overview_state.dart';
|
||||||
|
|
||||||
/// Cubit managing the benefits overview page state.
|
/// 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>
|
class BenefitsOverviewCubit extends Cubit<BenefitsOverviewState>
|
||||||
with BlocErrorHandler<BenefitsOverviewState> {
|
with BlocErrorHandler<BenefitsOverviewState> {
|
||||||
/// Creates a [BenefitsOverviewCubit].
|
/// Creates a [BenefitsOverviewCubit].
|
||||||
BenefitsOverviewCubit({required HomeRepository repository})
|
BenefitsOverviewCubit({
|
||||||
: _repository = repository,
|
required HomeRepository repository,
|
||||||
|
required GetBenefitsHistoryUseCase getBenefitsHistory,
|
||||||
|
}) : _repository = repository,
|
||||||
|
_getBenefitsHistory = getBenefitsHistory,
|
||||||
super(const BenefitsOverviewState.initial());
|
super(const BenefitsOverviewState.initial());
|
||||||
|
|
||||||
/// The repository used for data access.
|
/// The repository used for dashboard data access.
|
||||||
final HomeRepository _repository;
|
final HomeRepository _repository;
|
||||||
|
|
||||||
|
/// Use case for fetching benefit history.
|
||||||
|
final GetBenefitsHistoryUseCase _getBenefitsHistory;
|
||||||
|
|
||||||
/// Loads benefits from the dashboard endpoint.
|
/// Loads benefits from the dashboard endpoint.
|
||||||
Future<void> loadBenefits() async {
|
Future<void> loadBenefits() async {
|
||||||
if (isClosed) return;
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,78 @@
|
|||||||
part of 'benefits_overview_cubit.dart';
|
part of 'benefits_overview_cubit.dart';
|
||||||
|
|
||||||
|
/// Status of the benefits overview data fetch.
|
||||||
enum BenefitsOverviewStatus { initial, loading, loaded, error }
|
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 {
|
class BenefitsOverviewState extends Equatable {
|
||||||
final BenefitsOverviewStatus status;
|
/// Creates a [BenefitsOverviewState].
|
||||||
final List<Benefit> benefits;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
const BenefitsOverviewState({
|
const BenefitsOverviewState({
|
||||||
required this.status,
|
required this.status,
|
||||||
this.benefits = const [],
|
this.benefits = const <Benefit>[],
|
||||||
this.errorMessage,
|
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()
|
const BenefitsOverviewState.initial()
|
||||||
: this(status: BenefitsOverviewStatus.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({
|
BenefitsOverviewState copyWith({
|
||||||
BenefitsOverviewStatus? status,
|
BenefitsOverviewStatus? status,
|
||||||
List<Benefit>? benefits,
|
List<Benefit>? benefits,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
Map<String, List<BenefitHistory>>? historyByBenefitId,
|
||||||
|
Set<String>? loadingHistoryIds,
|
||||||
|
Set<String>? loadedHistoryIds,
|
||||||
|
Map<String, bool>? hasMoreHistory,
|
||||||
}) {
|
}) {
|
||||||
return BenefitsOverviewState(
|
return BenefitsOverviewState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
benefits: benefits ?? this.benefits,
|
benefits: benefits ?? this.benefits,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
historyByBenefitId: historyByBenefitId ?? this.historyByBenefitId,
|
||||||
|
loadingHistoryIds: loadingHistoryIds ?? this.loadingHistoryIds,
|
||||||
|
loadedHistoryIds: loadedHistoryIds ?? this.loadedHistoryIds,
|
||||||
|
hasMoreHistory: hasMoreHistory ?? this.hasMoreHistory,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [status, benefits, errorMessage];
|
List<Object?> get props => <Object?>[
|
||||||
|
status,
|
||||||
|
benefits,
|
||||||
|
errorMessage,
|
||||||
|
historyByBenefitId,
|
||||||
|
loadingHistoryIds,
|
||||||
|
loadedHistoryIds,
|
||||||
|
hasMoreHistory,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,8 @@ class BenefitsOverviewPage extends StatelessWidget {
|
|||||||
subtitle: t.staff.home.benefits.overview.subtitle,
|
subtitle: t.staff.home.benefits.overview.subtitle,
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
),
|
),
|
||||||
body: BlocProvider<BenefitsOverviewCubit>(
|
body: BlocProvider<BenefitsOverviewCubit>.value(
|
||||||
create: (context) =>
|
value: Modular.get<BenefitsOverviewCubit>()..loadBenefits(),
|
||||||
Modular.get<BenefitsOverviewCubit>()..loadBenefits(),
|
|
||||||
child: BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
|
child: BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.status == BenefitsOverviewStatus.loading ||
|
if (state.status == BenefitsOverviewStatus.loading ||
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:krow_domain/krow_domain.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_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 {
|
class BenefitCard extends StatelessWidget {
|
||||||
/// Creates a [BenefitCard].
|
/// Creates a [BenefitCard].
|
||||||
const BenefitCard({required this.benefit, super.key});
|
const BenefitCard({required this.benefit, super.key});
|
||||||
@@ -24,6 +25,11 @@ class BenefitCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
BenefitCardHeader(benefit: benefit),
|
BenefitCardHeader(benefit: benefit),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
BenefitHistoryPreview(
|
||||||
|
benefitId: benefit.benefitId,
|
||||||
|
benefitTitle: benefit.title,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import 'package:krow_core/core.dart';
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:staff_home/src/data/repositories/home_repository_impl.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/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/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/benefits_overview/benefits_overview_cubit.dart';
|
||||||
import 'package:staff_home/src/presentation/blocs/home/home_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/benefits_overview_page.dart';
|
||||||
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
||||||
|
|
||||||
@@ -33,6 +35,9 @@ class StaffHomeModule extends Module {
|
|||||||
i.addLazySingleton<GetProfileCompletionUseCase>(
|
i.addLazySingleton<GetProfileCompletionUseCase>(
|
||||||
() => GetProfileCompletionUseCase(i.get<HomeRepository>()),
|
() => GetProfileCompletionUseCase(i.get<HomeRepository>()),
|
||||||
);
|
);
|
||||||
|
i.addLazySingleton<GetBenefitsHistoryUseCase>(
|
||||||
|
() => GetBenefitsHistoryUseCase(i.get<HomeRepository>()),
|
||||||
|
);
|
||||||
|
|
||||||
// Presentation layer - Cubits
|
// Presentation layer - Cubits
|
||||||
i.addLazySingleton<HomeCubit>(
|
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>(
|
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),
|
StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefits),
|
||||||
child: (BuildContext context) => const BenefitsOverviewPage(),
|
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? ?? '',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user