Add client view orders feature module

Introduces the client 'View Orders' feature, including domain entity, repository, use case, Cubit, state, navigation extension, UI page, and widget. Integrates the feature into the client main module, updates localization files for English and Spanish, and adds supporting icons to the design system. Also updates the mock repository to provide sample order data.
This commit is contained in:
Achintha Isuru
2026-01-23 11:28:51 -05:00
parent a964fcabd7
commit 960b21ec8c
21 changed files with 1695 additions and 8 deletions

View File

@@ -0,0 +1,17 @@
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/i_view_orders_repository.dart';
/// Implementation of [IViewOrdersRepository] providing data from [OrderRepositoryMock].
class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
final OrderRepositoryMock _orderRepositoryMock;
/// Creates a [ViewOrdersRepositoryImpl] with the given [OrderRepositoryMock].
ViewOrdersRepositoryImpl({required OrderRepositoryMock orderRepositoryMock})
: _orderRepositoryMock = orderRepositoryMock;
@override
Future<List<OrderItem>> getOrders() {
return _orderRepositoryMock.getOrders();
}
}

View File

@@ -0,0 +1,7 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for fetching and managing client orders.
abstract class IViewOrdersRepository {
/// Fetches a list of [OrderItem] for the client.
Future<List<OrderItem>> getOrders();
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/i_view_orders_repository.dart';
/// Use case for retrieving the list of client orders.
///
/// This use case encapsulates the business rule of fetching orders
/// and delegates the data retrieval to the [IViewOrdersRepository].
class GetOrdersUseCase implements NoInputUseCase<List<OrderItem>> {
final IViewOrdersRepository _repository;
/// Creates a [GetOrdersUseCase] with the required [IViewOrdersRepository].
GetOrdersUseCase(this._repository);
@override
Future<List<OrderItem>> call() {
return _repository.getOrders();
}
}

View File

@@ -0,0 +1,156 @@
import 'package:intl/intl.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_orders_use_case.dart';
import 'view_orders_state.dart';
/// Cubit for managing the state of the View Orders feature.
///
/// This Cubit handles loading orders, date selection, and tab filtering.
class ViewOrdersCubit extends Cubit<ViewOrdersState> {
ViewOrdersCubit({required GetOrdersUseCase getOrdersUseCase})
: _getOrdersUseCase = getOrdersUseCase,
super(ViewOrdersState(selectedDate: DateTime.now())) {
_init();
}
final GetOrdersUseCase _getOrdersUseCase;
void _init() {
updateWeekOffset(0); // Initialize calendar days
loadOrders();
}
/// Loads the list of orders using the [GetOrdersUseCase].
Future<void> loadOrders() async {
emit(state.copyWith(status: ViewOrdersStatus.loading));
try {
final List<OrderItem> orders = await _getOrdersUseCase();
emit(state.copyWith(status: ViewOrdersStatus.success, orders: orders));
_updateDerivedState();
} catch (_) {
emit(state.copyWith(status: ViewOrdersStatus.failure));
}
}
void selectDate(DateTime date) {
emit(state.copyWith(selectedDate: date));
_updateDerivedState();
}
void selectFilterTab(String tabId) {
emit(state.copyWith(filterTab: tabId));
_updateDerivedState();
}
void updateWeekOffset(int offset) {
final int newWeekOffset = state.weekOffset + offset;
final List<DateTime> calendarDays = _calculateCalendarDays(newWeekOffset);
emit(state.copyWith(weekOffset: newWeekOffset, calendarDays: calendarDays));
_updateDerivedState();
}
void _updateDerivedState() {
final List<OrderItem> filteredOrders = _calculateFilteredOrders(state);
final int activeCount = _calculateCategoryCount('active');
final int completedCount = _calculateCategoryCount('completed');
final int upNextCount = _calculateUpNextCount();
emit(
state.copyWith(
filteredOrders: filteredOrders,
activeCount: activeCount,
completedCount: completedCount,
upNextCount: upNextCount,
),
);
}
List<DateTime> _calculateCalendarDays(int weekOffset) {
final DateTime now = DateTime.now();
final int jsDay = now.weekday == 7 ? 0 : now.weekday;
final int daysSinceFriday = (jsDay + 2) % 7;
final DateTime startDate = DateTime(now.year, now.month, now.day)
.subtract(Duration(days: daysSinceFriday))
.add(Duration(days: weekOffset * 7));
return List<DateTime>.generate(
7,
(int index) => startDate.add(Duration(days: index)),
);
}
List<OrderItem> _calculateFilteredOrders(ViewOrdersState state) {
if (state.selectedDate == null) return <OrderItem>[];
final String selectedDateStr = DateFormat(
'yyyy-MM-dd',
).format(state.selectedDate!);
// Filter by date
final List<OrderItem> ordersOnDate = state.orders
.where((OrderItem s) => s.date == selectedDateStr)
.toList();
// Sort by start time
ordersOnDate.sort(
(OrderItem a, OrderItem b) => a.startTime.compareTo(b.startTime),
);
if (state.filterTab == 'all') {
return ordersOnDate
.where(
(OrderItem s) =>
<String>['open', 'filled', 'confirmed'].contains(s.status),
)
.toList();
} else if (state.filterTab == 'active') {
return ordersOnDate
.where((OrderItem s) => s.status == 'in_progress')
.toList();
} else if (state.filterTab == 'completed') {
return ordersOnDate
.where((OrderItem s) => s.status == 'completed')
.toList();
}
return <OrderItem>[];
}
int _calculateCategoryCount(String category) {
if (state.selectedDate == null) return 0;
final String selectedDateStr = DateFormat(
'yyyy-MM-dd',
).format(state.selectedDate!);
final List<OrderItem> ordersOnDate = state.orders
.where((OrderItem s) => s.date == selectedDateStr)
.toList();
if (category == 'active') {
return ordersOnDate
.where((OrderItem s) => s.status == 'in_progress')
.length;
} else if (category == 'completed') {
return ordersOnDate
.where((OrderItem s) => s.status == 'completed')
.length;
}
return 0;
}
int _calculateUpNextCount() {
if (state.selectedDate == null) return 0;
final String selectedDateStr = DateFormat(
'yyyy-MM-dd',
).format(state.selectedDate!);
final List<OrderItem> ordersOnDate = state.orders
.where((OrderItem s) => s.date == selectedDateStr)
.toList();
return ordersOnDate
.where(
(OrderItem s) =>
<String>['open', 'filled', 'confirmed'].contains(s.status),
)
.length;
}
}

View File

@@ -0,0 +1,70 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
enum ViewOrdersStatus { initial, loading, success, failure }
class ViewOrdersState extends Equatable {
const ViewOrdersState({
this.status = ViewOrdersStatus.initial,
this.orders = const <OrderItem>[],
this.filteredOrders = const <OrderItem>[],
this.calendarDays = const <DateTime>[],
this.selectedDate,
this.filterTab = 'all',
this.weekOffset = 0,
this.activeCount = 0,
this.completedCount = 0,
this.upNextCount = 0,
});
final ViewOrdersStatus status;
final List<OrderItem> orders;
final List<OrderItem> filteredOrders;
final List<DateTime> calendarDays;
final DateTime? selectedDate;
final String filterTab;
final int weekOffset;
final int activeCount;
final int completedCount;
final int upNextCount;
ViewOrdersState copyWith({
ViewOrdersStatus? status,
List<OrderItem>? orders,
List<OrderItem>? filteredOrders,
List<DateTime>? calendarDays,
DateTime? selectedDate,
String? filterTab,
int? weekOffset,
int? activeCount,
int? completedCount,
int? upNextCount,
}) {
return ViewOrdersState(
status: status ?? this.status,
orders: orders ?? this.orders,
filteredOrders: filteredOrders ?? this.filteredOrders,
calendarDays: calendarDays ?? this.calendarDays,
selectedDate: selectedDate ?? this.selectedDate,
filterTab: filterTab ?? this.filterTab,
weekOffset: weekOffset ?? this.weekOffset,
activeCount: activeCount ?? this.activeCount,
completedCount: completedCount ?? this.completedCount,
upNextCount: upNextCount ?? this.upNextCount,
);
}
@override
List<Object?> get props => <Object?>[
status,
orders,
filteredOrders,
calendarDays,
selectedDate,
filterTab,
weekOffset,
activeCount,
completedCount,
upNextCount,
];
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter_modular/flutter_modular.dart';
/// Extension to provide typed navigation for the View Orders feature.
extension ViewOrdersNavigator on IModularNavigator {
/// Navigates to the Create Order feature.
void navigateToCreateOrder() {
pushNamed('/client/create-order/');
}
/// Navigates to the Order Details (placeholder for now).
void navigateToOrderDetails(String orderId) {
// pushNamed('/view-orders/$orderId');
}
}

View File

@@ -0,0 +1,472 @@
import 'dart:ui';
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:intl/intl.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/view_orders_cubit.dart';
import '../blocs/view_orders_state.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/view_order_card.dart';
import '../navigation/view_orders_navigator.dart';
/// The main page for viewing client orders.
///
/// This page follows the KROW Clean Architecture by:
/// - Being a [StatelessWidget].
/// - Using [ViewOrdersCubit] for state management.
/// - Adhering to the project's Design System.
class ViewOrdersPage extends StatelessWidget {
/// Creates a [ViewOrdersPage].
const ViewOrdersPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<ViewOrdersCubit>(
create: (BuildContext context) => Modular.get<ViewOrdersCubit>(),
child: const ViewOrdersView(),
);
}
}
/// The internal view implementation for [ViewOrdersPage].
class ViewOrdersView extends StatelessWidget {
/// Creates a [ViewOrdersView].
const ViewOrdersView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<ViewOrdersCubit, ViewOrdersState>(
builder: (BuildContext context, ViewOrdersState state) {
final List<DateTime> calendarDays = state.calendarDays;
final List<OrderItem> filteredOrders = state.filteredOrders;
// Header Colors logic from prototype
String sectionTitle = '';
Color dotColor = UiColors.transparent;
if (state.filterTab == 'all') {
sectionTitle = t.client_view_orders.tabs.up_next;
dotColor = UiColors.primary;
} else if (state.filterTab == 'active') {
sectionTitle = t.client_view_orders.tabs.active;
dotColor = UiColors.textWarning;
} else if (state.filterTab == 'completed') {
sectionTitle = t.client_view_orders.tabs.completed;
dotColor =
UiColors.primary; // Reverting to primary blue for consistency
}
return Scaffold(
backgroundColor: UiColors.white,
body: Stack(
children: <Widget>[
// Background Gradient
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[UiColors.bgSecondary, UiColors.white],
stops: <double>[0.0, 0.3],
),
),
),
SafeArea(
child: Column(
children: <Widget>[
// Header + Filter + Calendar (Sticky behavior)
_buildHeader(
context: context,
state: state,
calendarDays: calendarDays,
),
// Content List
Expanded(
child: filteredOrders.isEmpty
? _buildEmptyState(context: context, state: state)
: ListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space4,
UiConstants.space5,
100,
),
children: <Widget>[
if (filteredOrders.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
),
const SizedBox(
width: UiConstants.space2,
),
Text(
sectionTitle.toUpperCase(),
style: UiTypography.titleUppercase2m
.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(
width: UiConstants.space1,
),
Text(
'(${filteredOrders.length})',
style: UiTypography.footnote1r
.copyWith(
color: UiColors.textSecondary,
),
),
],
),
),
...filteredOrders.map(
(OrderItem order) => Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: ViewOrderCard(order: order),
),
),
],
),
),
],
),
),
],
),
);
},
);
}
/// Builds the sticky header section.
Widget _buildHeader({
required BuildContext context,
required ViewOrdersState state,
required List<DateTime> calendarDays,
}) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: const BoxDecoration(
color: Color(0xCCFFFFFF), // White with 0.8 alpha
border: Border(
bottom: BorderSide(color: UiColors.separatorSecondary),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// Top Bar
Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
UiConstants.space3,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_view_orders.title,
style: UiTypography.headline3m.copyWith(
color: UiColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
UiButton.primary(
text: t.client_view_orders.post_button,
leadingIcon: UiIcons.add,
onPressed: () => Modular.to.navigateToCreateOrder(),
size: UiButtonSize.small,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 48),
maximumSize: const Size(0, 48),
),
),
],
),
),
// Filter Tabs
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildFilterTab(
context,
label: t.client_view_orders.tabs.up_next,
isSelected: state.filterTab == 'all',
tabId: 'all',
),
const SizedBox(width: UiConstants.space6),
_buildFilterTab(
context,
label: t.client_view_orders.tabs.active,
isSelected: state.filterTab == 'active',
tabId: 'active',
count: state.activeCount + state.upNextCount,
),
const SizedBox(width: UiConstants.space6),
_buildFilterTab(
context,
label: t.client_view_orders.tabs.completed,
isSelected: state.filterTab == 'completed',
tabId: 'completed',
count: state.completedCount,
),
],
),
),
// Calendar Header controls
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(
icon: const Icon(
UiIcons.chevronLeft,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(-1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
Text(
DateFormat('MMMM yyyy').format(calendarDays.first),
style: UiTypography.body2m.copyWith(
color: UiColors.textSecondary,
),
),
IconButton(
icon: const Icon(
UiIcons.chevronRight,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
],
),
),
// Calendar Grid
SizedBox(
height: 72,
child: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
scrollDirection: Axis.horizontal,
itemCount: 7,
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: UiConstants.space2),
itemBuilder: (BuildContext context, int index) {
final DateTime date = calendarDays[index];
final bool isSelected =
state.selectedDate != null &&
date.year == state.selectedDate!.year &&
date.month == state.selectedDate!.month &&
date.day == state.selectedDate!.day;
// Check if this date has any shifts
final String dateStr = DateFormat(
'yyyy-MM-dd',
).format(date);
final bool hasShifts = state.orders.any(
(OrderItem s) => s.date == dateStr,
);
return GestureDetector(
onTap: () => BlocProvider.of<ViewOrdersCubit>(
context,
).selectDate(date),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 48,
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? UiColors.primary
: UiColors.separatorPrimary,
),
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(
alpha: 0.25,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('dd').format(date),
style: UiTypography.title2b.copyWith(
fontSize: 18,
color: isSelected
? UiColors.white
: UiColors.textPrimary,
),
),
Text(
DateFormat('E').format(date),
style: UiTypography.footnote2m.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)
: UiColors.textSecondary,
),
),
if (hasShifts) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected
? UiColors.white
: UiColors.primary,
shape: BoxShape.circle,
),
),
],
],
),
),
);
},
),
),
const SizedBox(height: UiConstants.space4),
],
),
),
),
);
}
/// Builds a single filter tab.
Widget _buildFilterTab(
BuildContext context, {
required String label,
required bool isSelected,
required String tabId,
int? count,
}) {
String text = label;
if (count != null) {
text = '$label ($count)';
}
return GestureDetector(
onTap: () =>
BlocProvider.of<ViewOrdersCubit>(context).selectFilterTab(tabId),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(
text,
style: UiTypography.body2m.copyWith(
color: isSelected ? UiColors.primary : UiColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 2,
width: isSelected ? 40 : 0,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
if (!isSelected) const SizedBox(height: 2),
],
),
);
}
/// Builds the empty state view.
Widget _buildEmptyState({
required BuildContext context,
required ViewOrdersState state,
}) {
final String dateStr = state.selectedDate != null
? _formatDateHeader(state.selectedDate!)
: 'this date';
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.calendar, size: 48, color: UiColors.iconInactive),
const SizedBox(height: UiConstants.space3),
Text(
t.client_view_orders.no_orders(date: dateStr),
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
),
const SizedBox(height: UiConstants.space4),
UiButton.primary(
text: t.client_view_orders.post_order,
leadingIcon: UiIcons.add,
onPressed: () => Modular.to.navigateToCreateOrder(),
),
],
),
);
}
static String _formatDateHeader(DateTime date) {
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime tomorrow = today.add(const Duration(days: 1));
final DateTime checkDate = DateTime(date.year, date.month, date.day);
if (checkDate == today) return 'Today';
if (checkDate == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
}
}

View File

@@ -0,0 +1,525 @@
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 rich card displaying details of a client order/shift.
///
/// This widget complies with the KROW Design System by using
/// tokens from `package:design_system`.
class ViewOrderCard extends StatefulWidget {
/// Creates a [ViewOrderCard] for the given [order].
const ViewOrderCard({required this.order, super.key});
/// The order item to display.
final OrderItem order;
@override
State<ViewOrderCard> createState() => _ViewOrderCardState();
}
class _ViewOrderCardState extends State<ViewOrderCard> {
bool _expanded = false;
/// Returns the semantic color for the given status.
Color _getStatusColor({required String status}) {
switch (status) {
case 'open':
return UiColors.primary;
case 'filled':
case 'confirmed':
return UiColors.textSuccess;
case 'in_progress':
return UiColors.textWarning;
case 'completed':
return UiColors.primary;
case 'cancelled':
return UiColors.destructive;
default:
return UiColors.textSecondary;
}
}
/// Returns the localized label for the given status.
String _getStatusLabel({required String status}) {
switch (status) {
case 'open':
return t.client_view_orders.card.open;
case 'filled':
return t.client_view_orders.card.filled;
case 'confirmed':
return t.client_view_orders.card.confirmed;
case 'in_progress':
return t.client_view_orders.card.in_progress;
case 'completed':
return t.client_view_orders.card.completed;
case 'cancelled':
return t.client_view_orders.card.cancelled;
default:
return status.toUpperCase();
}
}
/// Formats the date string for display.
String _formatDate({required String dateStr}) {
try {
final DateTime date = DateTime.parse(dateStr);
return DateFormat('EEE, MMM d').format(date);
} catch (_) {
return dateStr;
}
}
/// Formats the time string for display.
String _formatTime({required String timeStr}) {
return timeStr;
}
@override
Widget build(BuildContext context) {
final OrderItem order = widget.order;
final Color statusColor = _getStatusColor(status: order.status);
final String statusLabel = _getStatusLabel(status: order.status);
final int coveragePercent = order.workersNeeded > 0
? ((order.filled / order.workersNeeded) * 100).round()
: 0;
// Simulation of cost/hours calculation
const double hours = 8.0;
final double cost =
order.hourlyRate *
hours *
(order.filled > 0 ? order.filled : order.workersNeeded);
return Container(
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: UiColors.primary.withValues(alpha: 0.12),
width: 1.5,
),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(alpha: 0.08),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Header Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Status Dot & Label
Row(
children: <Widget>[
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: UiConstants.space2),
Text(
statusLabel,
style: UiTypography.footnote2b.copyWith(
color: statusColor,
letterSpacing: 0.5,
),
),
],
),
const SizedBox(height: UiConstants.space1),
// Title
Text(
order.title,
style: UiTypography.body1b.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(height: UiConstants.space1),
// Client & Date
Row(
children: <Widget>[
Text(
order.clientName,
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space1,
),
child: Text(
'',
style: UiTypography.body3r.copyWith(
color: UiColors.textInactive,
),
),
),
Text(
_formatDate(dateStr: order.date),
style: UiTypography.body3r.copyWith(
fontWeight: FontWeight.w500,
color: UiColors.textSecondary,
),
),
],
),
const SizedBox(height: UiConstants.space1),
// Address
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
Expanded(
child: Text(
order.locationAddress,
style: UiTypography.footnote2r.copyWith(
color: UiColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: UiConstants.space1),
GestureDetector(
onTap: () {
// TODO: Get directions
},
child: Row(
children: <Widget>[
const Icon(
UiIcons.navigation,
size: 12,
color: UiColors.primary,
),
const SizedBox(width: 2),
Text(
t.client_view_orders.card.get_direction,
style: UiTypography.footnote2m.copyWith(
color: UiColors.primary,
),
),
],
),
),
],
),
],
),
),
// Actions
Row(
children: <Widget>[
_buildHeaderIconButton(
icon: UiIcons.edit,
color: UiColors.primary,
bgColor: UiColors.tagInProgress,
onTap: () {
// TODO: Open edit sheet
},
),
const SizedBox(width: UiConstants.space2),
_buildHeaderIconButton(
icon: _expanded
? UiIcons.chevronUp
: UiIcons.chevronDown,
color: UiColors.iconSecondary,
bgColor: UiColors.bgSecondary,
onTap: () => setState(() => _expanded = !_expanded),
),
],
),
],
),
const SizedBox(height: UiConstants.space3),
const Divider(height: 1, color: UiColors.separatorSecondary),
const SizedBox(height: UiConstants.space3),
// Stats Row
Row(
children: <Widget>[
Expanded(
child: _buildStatItem(
icon: UiIcons.dollar,
value: '\$${cost.round()}',
label: t.client_view_orders.card.total,
),
),
Container(
width: 1,
height: 32,
color: UiColors.separatorSecondary,
),
Expanded(
child: _buildStatItem(
icon: UiIcons.clock,
value: hours.toStringAsFixed(1),
label: t.client_view_orders.card.hrs,
),
),
Container(
width: 1,
height: 32,
color: UiColors.separatorSecondary,
),
Expanded(
child: _buildStatItem(
icon: UiIcons.users,
value:
'${order.filled > 0 ? order.filled : order.workersNeeded}',
label: t.client_view_orders.card.workers,
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Clock In/Out Boxes
Row(
children: <Widget>[
Expanded(
child: _buildTimeBox(
label: t.client_view_orders.card.clock_in,
time: _formatTime(timeStr: order.startTime),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildTimeBox(
label: t.client_view_orders.card.clock_out,
time: _formatTime(timeStr: order.endTime),
),
),
],
),
const SizedBox(height: UiConstants.space3),
// Coverage Bar
if (order.status != 'completed') ...<Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Text(
t.client_view_orders.card.coverage,
style: UiTypography.footnote2b.copyWith(
color: UiColors.textSecondary,
),
),
const SizedBox(width: UiConstants.space1),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: coveragePercent == 100
? UiColors.tagSuccess
: UiColors.tagInProgress,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$coveragePercent%',
style: UiTypography.footnote2b.copyWith(
fontSize: 9,
color: coveragePercent == 100
? UiColors.textSuccess
: UiColors.primary,
),
),
),
],
),
Text(
t.client_view_orders.card.workers_label(
filled: order.filled,
needed: order.workersNeeded,
),
style: UiTypography.footnote2m.copyWith(
color: UiColors.textSecondary,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: coveragePercent / 100,
backgroundColor: UiColors.separatorSecondary,
valueColor: AlwaysStoppedAnimation<Color>(
coveragePercent == 100
? UiColors.textSuccess
: UiColors.primary,
),
minHeight: 4,
),
),
],
],
),
),
// Worker Avatars and more details (Expanded section)
if (_expanded) ...<Widget>[
const Divider(height: 1, color: UiColors.separatorSecondary),
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.client_view_orders.card.confirmed_workers,
style: UiTypography.body2b.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(height: UiConstants.space3),
if (order.confirmedApps.isEmpty)
Text(
t.client_view_orders.card.no_workers,
style: UiTypography.body3r.copyWith(
color: UiColors.textInactive,
),
)
else
Wrap(
spacing: -8,
children: order.confirmedApps
.map(
(Map<String, dynamic> app) => Tooltip(
message: app['worker_name'] as String,
child: CircleAvatar(
radius: 14,
backgroundColor: UiColors.white,
child: CircleAvatar(
radius: 12,
backgroundColor: UiColors.bgSecondary,
child: Text(
(app['worker_name'] as String).substring(
0,
1,
),
style: UiTypography.footnote2b,
),
),
),
),
)
.toList(),
),
],
),
),
],
],
),
);
}
/// Builds a small icon button used in row headers.
Widget _buildHeaderIconButton({
required IconData icon,
required Color color,
required Color bgColor,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 14, color: color),
),
);
}
/// Builds a single stat item (e.g., Cost, Hours, Workers).
Widget _buildStatItem({
required IconData icon,
required String value,
required String label,
}) {
return Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: 10, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space1),
Text(
value,
style: UiTypography.body2b.copyWith(color: UiColors.textPrimary),
),
],
),
Text(
label.toUpperCase(),
style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textInactive,
fontWeight: FontWeight.bold,
),
),
],
);
}
/// Builds a box displaying a label and a time value.
Widget _buildTimeBox({required String label, required String time}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: <Widget>[
Text(
label,
style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSecondary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
time,
style: UiTypography.body2m.copyWith(
color: UiColors.foreground,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'data/repositories/view_orders_repository_impl.dart';
import 'domain/repositories/i_view_orders_repository.dart';
import 'domain/usecases/get_orders_use_case.dart';
import 'presentation/blocs/view_orders_cubit.dart';
import 'presentation/pages/view_orders_page.dart';
/// Module for the View Orders feature.
///
/// This module sets up Dependency Injection for repositories, use cases,
/// and BLoCs, and defines the feature's navigation routes.
class ViewOrdersModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<IViewOrdersRepository>(
() => ViewOrdersRepositoryImpl(
orderRepositoryMock: i.get<OrderRepositoryMock>(),
),
);
// UseCases
i.addLazySingleton(GetOrdersUseCase.new);
// BLoCs
i.addSingleton(ViewOrdersCubit.new);
}
@override
void routes(RouteManager r) {
r.child('/', child: (BuildContext context) => const ViewOrdersPage());
}
}

View File

@@ -0,0 +1,3 @@
library;
export 'src/view_orders_module.dart';

View File

@@ -0,0 +1,42 @@
name: view_orders
description: Client View Orders feature package
publish_to: 'none'
version: 1.0.0+1
resolution: workspace
environment:
sdk: '>=3.10.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# Architecture
flutter_modular: ^6.3.2
flutter_bloc: ^8.1.3
equatable: ^2.0.5
# Shared packages
design_system:
path: ../../../design_system
core_localization:
path: ../../../core_localization
krow_domain:
path: ../../../domain
krow_core:
path: ../../../core
# UI
lucide_icons: ^0.257.0
intl: ^0.20.1
url_launcher: ^6.3.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
bloc_test: ^9.1.5
mocktail: ^1.0.1
flutter:
uses-material-design: true