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

@@ -311,6 +311,35 @@
"orders": "Orders", "orders": "Orders",
"reports": "Reports" "reports": "Reports"
} }
},
"client_view_orders": {
"title": "Orders",
"post_button": "Post",
"post_order": "Post an Order",
"no_orders": "No orders for $date",
"tabs": {
"up_next": "Up Next",
"active": "Active",
"completed": "Completed"
},
"card": {
"open": "OPEN",
"filled": "FILLED",
"confirmed": "CONFIRMED",
"in_progress": "IN PROGRESS",
"completed": "COMPLETED",
"cancelled": "CANCELLED",
"get_direction": "Get direction",
"total": "Total",
"hrs": "HRS",
"workers": "workers",
"clock_in": "CLOCK IN",
"clock_out": "CLOCK OUT",
"coverage": "Coverage",
"workers_label": "$filled/$needed Workers",
"confirmed_workers": "Workers Confirmed",
"no_workers": "No workers confirmed yet."
}
} }
} }

View File

@@ -311,5 +311,34 @@
"orders": "Órdenes", "orders": "Órdenes",
"reports": "Reportes" "reports": "Reportes"
} }
},
"client_view_orders": {
"title": "Órdenes",
"post_button": "Publicar",
"post_order": "Publicar una Orden",
"no_orders": "No hay órdenes para $date",
"tabs": {
"up_next": "Próximos",
"active": "Activos",
"completed": "Completados"
},
"card": {
"open": "ABIERTO",
"filled": "LLENO",
"confirmed": "CONFIRMADO",
"in_progress": "EN PROGRESO",
"completed": "COMPLETADO",
"cancelled": "CANCELADO",
"get_direction": "Obtener dirección",
"total": "Total",
"hrs": "HRS",
"workers": "trabajadores",
"clock_in": "ENTRADA",
"clock_out": "SALIDA",
"coverage": "Cobertura",
"workers_label": "$filled/$needed Trabajadores",
"confirmed_workers": "Trabajadores Confirmados",
"no_workers": "Ningún trabajador confirmado aún."
}
} }
} }

View File

@@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart';
class OrderRepositoryMock { class OrderRepositoryMock {
/// Returns a list of available [OrderType]s. /// Returns a list of available [OrderType]s.
Future<List<OrderType>> getOrderTypes() async { Future<List<OrderType>> getOrderTypes() async {
await Future.delayed(const Duration(milliseconds: 500)); await Future<void>.delayed(const Duration(milliseconds: 500));
return const <OrderType>[ return const <OrderType>[
OrderType( OrderType(
id: 'rapid', id: 'rapid',
@@ -34,11 +34,122 @@ class OrderRepositoryMock {
/// Simulates creating a one-time order. /// Simulates creating a one-time order.
Future<void> createOneTimeOrder(OneTimeOrder order) async { Future<void> createOneTimeOrder(OneTimeOrder order) async {
await Future.delayed(const Duration(milliseconds: 800)); await Future<void>.delayed(const Duration(milliseconds: 800));
} }
/// Simulates creating a rapid order. /// Simulates creating a rapid order.
Future<void> createRapidOrder(String description) async { Future<void> createRapidOrder(String description) async {
await Future.delayed(const Duration(seconds: 1)); await Future<void>.delayed(const Duration(seconds: 1));
}
/// Returns a mock list of client orders.
Future<List<OrderItem>> getOrders() async {
await Future<void>.delayed(const Duration(milliseconds: 500));
return <OrderItem>[
OrderItem(
id: '1',
title: 'Server - Wedding',
clientName: 'Grand Plaza Hotel',
status: 'filled',
date: DateTime.now()
.add(const Duration(days: 1))
.toIso8601String()
.split('T')[0],
startTime: '16:00',
endTime: '23:00',
location: 'Grand Plaza Hotel, 123 Main St',
locationAddress: 'Grand Plaza Hotel, 123 Main St',
filled: 10,
workersNeeded: 10,
hourlyRate: 22.0,
confirmedApps: List<Map<String, dynamic>>.generate(
10,
(int index) => <String, dynamic>{
'id': 'app_$index',
'worker_id': 'w_$index',
'worker_name': 'Worker ${String.fromCharCode(65 + index)}',
'status': 'confirmed',
'check_in_time': index < 5 ? '15:55' : null,
},
),
),
OrderItem(
id: '2',
title: 'Bartender - Private Event',
clientName: 'Taste of the Town',
status: 'open',
date: DateTime.now()
.add(const Duration(days: 1))
.toIso8601String()
.split('T')[0],
startTime: '18:00',
endTime: '02:00',
location: 'Downtown Loft, 456 High St',
locationAddress: 'Downtown Loft, 456 High St',
filled: 4,
workersNeeded: 5,
hourlyRate: 28.0,
confirmedApps: List<Map<String, dynamic>>.generate(
4,
(int index) => <String, dynamic>{
'id': 'app_b_$index',
'worker_id': 'w_b_$index',
'worker_name': 'Bartender ${index + 1}',
'status': 'confirmed',
},
),
),
OrderItem(
id: '3',
title: 'Event Staff',
clientName: 'City Center',
status: 'in_progress',
date: DateTime.now().toIso8601String().split('T')[0],
startTime: '08:00',
endTime: '16:00',
location: 'Convention Center, 789 Blvd',
locationAddress: 'Convention Center, 789 Blvd',
filled: 15,
workersNeeded: 15,
hourlyRate: 20.0,
confirmedApps: List<Map<String, dynamic>>.generate(
15,
(int index) => <String, dynamic>{
'id': 'app_c_$index',
'worker_id': 'w_c_$index',
'worker_name': 'Staff ${index + 1}',
'status': 'confirmed',
'check_in_time': '07:55',
},
),
),
OrderItem(
id: '4',
title: 'Coat Check',
clientName: 'The Met Museum',
status: 'completed',
date: DateTime.now()
.subtract(const Duration(days: 1))
.toIso8601String()
.split('T')[0],
startTime: '17:00',
endTime: '22:00',
location: 'The Met Museum, 1000 5th Ave',
locationAddress: 'The Met Museum, 1000 5th Ave',
filled: 2,
workersNeeded: 2,
hourlyRate: 18.0,
confirmedApps: List<Map<String, dynamic>>.generate(
2,
(int index) => <String, dynamic>{
'id': 'app_d_$index',
'worker_id': 'w_d_$index',
'worker_name': 'Checker ${index + 1}',
'status': 'confirmed',
'check_in_time': '16:50',
},
),
),
];
} }
} }

View File

@@ -34,6 +34,9 @@ class UiIcons {
/// User icon for profile /// User icon for profile
static const IconData user = _IconLib.user; static const IconData user = _IconLib.user;
/// Users icon for groups or staff
static const IconData users = _IconLib.users;
/// Settings icon /// Settings icon
static const IconData settings = _IconLib.settings; static const IconData settings = _IconLib.settings;
@@ -81,6 +84,9 @@ class UiIcons {
/// Chevron down icon /// Chevron down icon
static const IconData chevronDown = _IconLib.chevronDown; static const IconData chevronDown = _IconLib.chevronDown;
/// Chevron up icon
static const IconData chevronUp = _IconLib.chevronUp;
// --- Status & Feedback --- // --- Status & Feedback ---
/// Info icon /// Info icon
@@ -139,6 +145,9 @@ class UiIcons {
/// Sparkles icon for features or AI /// Sparkles icon for features or AI
static const IconData sparkles = _IconLib.sparkles; static const IconData sparkles = _IconLib.sparkles;
/// Navigation/Compass icon
static const IconData navigation = _IconLib.navigation;
/// Star icon for ratings /// Star icon for ratings
static const IconData star = _IconLib.star; static const IconData star = _IconLib.star;

View File

@@ -31,6 +31,7 @@ export 'src/entities/events/work_session.dart';
export 'src/entities/orders/order_type.dart'; export 'src/entities/orders/order_type.dart';
export 'src/entities/orders/one_time_order.dart'; export 'src/entities/orders/one_time_order.dart';
export 'src/entities/orders/one_time_order_position.dart'; export 'src/entities/orders/one_time_order_position.dart';
export 'src/entities/orders/order_item.dart';
// Skills & Certs // Skills & Certs
export 'src/entities/skills/skill.dart'; export 'src/entities/skills/skill.dart';

View File

@@ -0,0 +1,80 @@
import 'package:equatable/equatable.dart';
/// Represents a customer's view of an order or shift.
///
/// This entity captures the details necessary for the dashboard/view orders screen,
/// including status and worker assignments.
class OrderItem extends Equatable {
/// Creates an [OrderItem].
const OrderItem({
required this.id,
required this.title,
required this.clientName,
required this.status,
required this.date,
required this.startTime,
required this.endTime,
required this.location,
required this.locationAddress,
required this.filled,
required this.workersNeeded,
required this.hourlyRate,
this.confirmedApps = const <Map<String, dynamic>>[],
});
/// Unique identifier of the order.
final String id;
/// Title or name of the role.
final String title;
/// Name of the client company.
final String clientName;
/// status of the order (e.g., 'open', 'filled', 'completed').
final String status;
/// Date of the shift (ISO format).
final String date;
/// Start time of the shift.
final String startTime;
/// End time of the shift.
final String endTime;
/// Location name.
final String location;
/// Full address of the location.
final String locationAddress;
/// Number of workers currently filled.
final int filled;
/// Total number of workers required.
final int workersNeeded;
/// Hourly pay rate.
final double hourlyRate;
/// List of confirmed worker applications.
final List<Map<String, dynamic>> confirmedApps;
@override
List<Object?> get props => <Object?>[
id,
title,
clientName,
status,
date,
startTime,
endTime,
location,
locationAddress,
filled,
workersNeeded,
hourlyRate,
confirmedApps,
];
}

View File

@@ -1,6 +1,7 @@
import 'package:client_home/client_home.dart'; import 'package:client_home/client_home.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:view_orders/view_orders.dart';
import 'presentation/blocs/client_main_cubit.dart'; import 'presentation/blocs/client_main_cubit.dart';
import 'presentation/pages/client_main_page.dart'; import 'presentation/pages/client_main_page.dart';
@@ -30,11 +31,7 @@ class ClientMainModule extends Module {
child: (BuildContext context) => child: (BuildContext context) =>
const PlaceholderPage(title: 'Billing'), const PlaceholderPage(title: 'Billing'),
), ),
ChildRoute<dynamic>( ModuleRoute<dynamic>('/orders', module: ViewOrdersModule()),
'/orders',
child: (BuildContext context) =>
const PlaceholderPage(title: 'Orders'),
),
ChildRoute<dynamic>( ChildRoute<dynamic>(
'/reports', '/reports',
child: (BuildContext context) => child: (BuildContext context) =>

View File

@@ -23,6 +23,8 @@ dependencies:
path: ../../../core_localization path: ../../../core_localization
client_home: client_home:
path: ../home path: ../home
view_orders:
path: ../view_orders
# Intentionally commenting these out as they might not exist yet # Intentionally commenting these out as they might not exist yet
# client_settings: # client_settings:
# path: ../settings # path: ../settings

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

View File

@@ -1114,6 +1114,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher:
dependency: transitive
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
url: "https://pub.dev"
source: hosted
version: "6.3.28"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
url: "https://pub.dev"
source: hosted
version: "6.3.6"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: transitive
description: description:

View File

@@ -15,6 +15,7 @@ workspace:
- packages/features/client/settings - packages/features/client/settings
- packages/features/client/hubs - packages/features/client/hubs
- packages/features/client/create_order - packages/features/client/create_order
- packages/features/client/view_orders
- packages/features/client/client_main - packages/features/client/client_main
- apps/staff - apps/staff
- apps/client - apps/client