feat: Centralized Error Handling & Crash Fixes

This commit is contained in:
2026-02-11 18:52:23 +05:30
parent ea06510474
commit c1112ac01c
51 changed files with 2104 additions and 960 deletions

View File

@@ -12,7 +12,9 @@ import 'package:krow_domain/krow_domain.dart'
AccountExistsException,
UserNotFoundException,
UnauthorizedAppException,
PasswordMismatchException;
UnauthorizedAppException,
PasswordMismatchException,
NetworkException;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/auth_repository_interface.dart';
@@ -63,6 +65,10 @@ class AuthRepositoryImpl
throw InvalidCredentialsException(
technicalMessage: 'Firebase error code: ${e.code}',
);
} else if (e.code == 'network-request-failed') {
throw NetworkException(
technicalMessage: 'Firebase: ${e.message}',
);
} else {
throw SignInFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
@@ -120,6 +126,10 @@ class AuthRepositoryImpl
password: password,
companyName: companyName,
);
} else if (e.code == 'network-request-failed') {
throw NetworkException(
technicalMessage: 'Firebase: ${e.message}',
);
} else {
throw SignUpFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',

View File

@@ -47,8 +47,11 @@ class ClientSignInPage extends StatelessWidget {
final String errorMessage = state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: t.errors.generic.unknown;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorMessage)),
UiSnackbar.show(
context,
message: errorMessage,
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16),
);
}
},

View File

@@ -51,8 +51,11 @@ class ClientSignUpPage extends StatelessWidget {
final String errorMessage = state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: t.errors.generic.unknown;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorMessage)),
UiSnackbar.show(
context,
message: errorMessage,
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 120, left: 16, right: 16),
);
}
},

View File

@@ -47,9 +47,11 @@ class _ClientSignUpFormState extends State<ClientSignUpForm> {
void _handleSubmit() {
if (_passwordController.text != _confirmPasswordController.text) {
ScaffoldMessenger.of(
UiSnackbar.show(
context,
).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
message: translateErrorKey('passwords_dont_match'),
type: UiSnackbarType.error,
);
return;
}

View File

@@ -71,13 +71,26 @@ class _BillingViewState extends State<BillingView> {
@override
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
return BlocConsumer<BillingBloc, BillingState>(
listener: (BuildContext context, BillingState state) {
if (state.status == BillingStatus.failure && state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, BillingState state) {
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: <Widget>[
SliverAppBar(
// ... (APP BAR CODE REMAINS UNCHANGED, BUT I MUST INCLUDE IT OR CHUNK IT CORRECTLY)
// Since I cannot see the headers in this chunk, I will target the _buildContent method instead
// to avoid messing up the whole file structure.
// Wait, I can just replace the build method wrapper.
pinned: true,
expandedHeight: 200.0,
backgroundColor: UiColors.primary,
@@ -180,17 +193,28 @@ class _BillingViewState extends State<BillingView> {
}
if (state.status == BillingStatus.failure) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(translateErrorKey(state.errorMessage!))),
);
});
return Center(
child: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
style: UiTypography.body1r.textError,
return Padding(
padding: const EdgeInsets.all(UiConstants.space8),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.error, size: 48, color: UiColors.error),
const SizedBox(height: UiConstants.space4),
Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
onPressed: () => BlocProvider.of<BillingBloc>(context).add(const BillingLoadStarted()),
),
],
),
),
);
}

View File

@@ -23,43 +23,56 @@ class CoverageRepositoryImpl implements CoverageRepository {
/// Fetches shifts for a specific date.
@override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <CoverageShift>[];
try {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <CoverageShift>[];
}
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end =
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
await _dataConnect
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute();
final fdc.QueryResult<
dc.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
await _dataConnect
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _toTimestamp(start),
dayEnd: _toTimestamp(end),
)
.execute();
return _mapCoverageShifts(
shiftRolesResult.data.shiftRoles,
applicationsResult.data.applications,
date,
);
} catch (e) {
final String error = e.toString().toLowerCase();
if (error.contains('network') ||
error.contains('connection') ||
error.contains('unavailable') ||
error.contains('offline') ||
error.contains('socket') ||
error.contains('failed host lookup')) {
throw NetworkException(technicalMessage: 'Coverage fetch failed: $e');
}
throw ServerException(technicalMessage: 'Coverage fetch failed: $e');
}
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end =
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
await _dataConnect
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute();
final fdc.QueryResult<
dc.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
await _dataConnect
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _toTimestamp(start),
dayEnd: _toTimestamp(end),
)
.execute();
return _mapCoverageShifts(
shiftRolesResult.data.shiftRoles,
applicationsResult.data.applications,
date,
);
}
/// Fetches coverage statistics for a specific date.
@@ -180,6 +193,7 @@ class CoverageRepositoryImpl implements CoverageRepository {
case dc.ApplicationStatus.REJECTED:
return CoverageWorkerStatus.rejected;
case dc.ApplicationStatus.CONFIRMED:
case dc.ApplicationStatus.ACCEPTED:
return CoverageWorkerStatus.confirmed;
case dc.ApplicationStatus.CHECKED_IN:
return CoverageWorkerStatus.checkedIn;

View File

@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/coverage_bloc.dart';
import '../blocs/coverage_event.dart';
import '../blocs/coverage_state.dart';
@@ -57,7 +58,16 @@ class _CoveragePageState extends State<CoveragePage> {
create: (BuildContext context) => Modular.get<CoverageBloc>()
..add(CoverageLoadRequested(date: DateTime.now())),
child: Scaffold(
body: BlocBuilder<CoverageBloc, CoverageState>(
body: BlocConsumer<CoverageBloc, CoverageState>(
listener: (BuildContext context, CoverageState state) {
if (state.status == CoverageStatus.failure && state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, CoverageState state) {
final DateTime selectedDate = state.selectedDate ?? DateTime.now();
@@ -226,43 +236,45 @@ class _CoveragePageState extends State<CoveragePage> {
required BuildContext context,
required CoverageState state,
}) {
if (state.status == CoverageStatus.loading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.shifts.isEmpty) {
if (state.status == CoverageStatus.loading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.status == CoverageStatus.failure) {
return Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space6),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.warning,
size: UiConstants.space12,
color: UiColors.destructive,
),
const SizedBox(height: UiConstants.space4),
Text(
'Failed to load coverage data',
style: UiTypography.title2m.copyWith(
color: UiColors.textPrimary,
if (state.status == CoverageStatus.failure) {
return Center(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space6),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.error,
size: 48,
color: UiColors.error,
),
),
const SizedBox(height: UiConstants.space2),
Text(
state.errorMessage ?? 'An unknown error occurred',
style: UiTypography.body2r.copyWith(
color: UiColors.mutedForeground,
const SizedBox(height: UiConstants.space4),
Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
onPressed: () => BlocProvider.of<CoverageBloc>(context).add(
const CoverageRefreshRequested(),
),
),
],
),
),
),
);
);
}
}
return Padding(

View File

@@ -10,7 +10,7 @@ import 'one_time_order_state.dart';
/// BLoC for managing the multi-step one-time order creation form.
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
with BlocErrorHandler<OneTimeOrderState> {
with BlocErrorHandler<OneTimeOrderState>, SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._dataConnect)
: super(OneTimeOrderState.initial()) {
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);

View File

@@ -71,6 +71,20 @@ class OneTimeOrderState extends Equatable {
);
}
bool get isValid {
return eventName.isNotEmpty &&
selectedVendor != null &&
selectedHub != null &&
positions.isNotEmpty &&
positions.every(
(OneTimeOrderPosition p) =>
p.role.isNotEmpty &&
p.count > 0 &&
p.startTime.isNotEmpty &&
p.endTime.isNotEmpty,
);
}
@override
List<Object?> get props => <Object?>[
date,

View File

@@ -25,7 +25,18 @@ class OneTimeOrderView extends StatelessWidget {
final TranslationsClientCreateOrderOneTimeEn labels =
t.client_create_order.one_time;
return BlocBuilder<OneTimeOrderBloc, OneTimeOrderState>(
return BlocConsumer<OneTimeOrderBloc, OneTimeOrderState>(
listener: (BuildContext context, OneTimeOrderState state) {
if (state.status == OneTimeOrderStatus.failure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16),
);
}
},
builder: (BuildContext context, OneTimeOrderState state) {
if (state.status == OneTimeOrderStatus.success) {
return OneTimeOrderSuccessView(
@@ -104,9 +115,11 @@ class OneTimeOrderView extends StatelessWidget {
? labels.creating
: labels.create_order,
isLoading: state.status == OneTimeOrderStatus.loading,
onPressed: () => BlocProvider.of<OneTimeOrderBloc>(
context,
).add(const OneTimeOrderSubmitted()),
onPressed: state.isValid
? () => BlocProvider.of<OneTimeOrderBloc>(
context,
).add(const OneTimeOrderSubmitted())
: null,
),
],
),
@@ -286,7 +299,7 @@ class _BottomActionButton extends StatelessWidget {
this.isLoading = false,
});
final String label;
final VoidCallback onPressed;
final VoidCallback? onPressed;
final bool isLoading;
@override

View File

@@ -15,92 +15,103 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
@override
Future<HomeDashboardData> getDashboardData() async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return const HomeDashboardData(
weeklySpending: 0,
next7DaysSpending: 0,
weeklyShifts: 0,
next7DaysScheduled: 0,
totalNeeded: 0,
totalFilled: 0,
try {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return const HomeDashboardData(
weeklySpending: 0,
next7DaysSpending: 0,
weeklyShifts: 0,
next7DaysScheduled: 0,
totalNeeded: 0,
totalFilled: 0,
);
}
final DateTime now = DateTime.now();
final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday =
DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday));
final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day);
final DateTime weekRangeEnd =
DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999);
final fdc.QueryResult<
dc.GetCompletedShiftsByBusinessIdData,
dc.GetCompletedShiftsByBusinessIdVariables> completedResult =
await _dataConnect
.getCompletedShiftsByBusinessId(
businessId: businessId,
dateFrom: _toTimestamp(weekRangeStart),
dateTo: _toTimestamp(weekRangeEnd),
)
.execute();
double weeklySpending = 0.0;
double next7DaysSpending = 0.0;
int weeklyShifts = 0;
int next7DaysScheduled = 0;
for (final dc.GetCompletedShiftsByBusinessIdShifts shift
in completedResult.data.shifts) {
final DateTime? shiftDate = shift.date?.toDateTime();
if (shiftDate == null) {
continue;
}
final int offset = shiftDate.difference(weekRangeStart).inDays;
if (offset < 0 || offset > 13) {
continue;
}
final double cost = shift.cost ?? 0.0;
if (offset <= 6) {
weeklySpending += cost;
weeklyShifts += 1;
} else {
next7DaysSpending += cost;
next7DaysScheduled += 1;
}
}
final DateTime start = DateTime(now.year, now.month, now.day);
final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> result =
await _dataConnect
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute();
int totalNeeded = 0;
int totalFilled = 0;
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
in result.data.shiftRoles) {
totalNeeded += shiftRole.count;
totalFilled += shiftRole.assigned ?? 0;
}
return HomeDashboardData(
weeklySpending: weeklySpending,
next7DaysSpending: next7DaysSpending,
weeklyShifts: weeklyShifts,
next7DaysScheduled: next7DaysScheduled,
totalNeeded: totalNeeded,
totalFilled: totalFilled,
);
}
final DateTime now = DateTime.now();
final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday =
DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday));
final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day);
final DateTime weekRangeEnd =
DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999);
final fdc.QueryResult<
dc.GetCompletedShiftsByBusinessIdData,
dc.GetCompletedShiftsByBusinessIdVariables> completedResult =
await _dataConnect
.getCompletedShiftsByBusinessId(
businessId: businessId,
dateFrom: _toTimestamp(weekRangeStart),
dateTo: _toTimestamp(weekRangeEnd),
)
.execute();
double weeklySpending = 0.0;
double next7DaysSpending = 0.0;
int weeklyShifts = 0;
int next7DaysScheduled = 0;
for (final dc.GetCompletedShiftsByBusinessIdShifts shift
in completedResult.data.shifts) {
final DateTime? shiftDate = shift.date?.toDateTime();
if (shiftDate == null) {
continue;
}
final int offset = shiftDate.difference(weekRangeStart).inDays;
if (offset < 0 || offset > 13) {
continue;
}
final double cost = shift.cost ?? 0.0;
if (offset <= 6) {
weeklySpending += cost;
weeklyShifts += 1;
} else {
next7DaysSpending += cost;
next7DaysScheduled += 1;
} catch (e) {
final String error = e.toString().toLowerCase();
if (error.contains('network') ||
error.contains('connection') ||
error.contains('unavailable') ||
error.contains('offline') ||
error.contains('socket') ||
error.contains('failed host lookup')) {
throw NetworkException(technicalMessage: 'Home dashboard fetch failed: $e');
}
throw ServerException(technicalMessage: 'Home dashboard fetch failed: $e');
}
final DateTime start = DateTime(now.year, now.month, now.day);
final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> result =
await _dataConnect
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute();
int totalNeeded = 0;
int totalFilled = 0;
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
in result.data.shiftRoles) {
totalNeeded += shiftRole.count;
totalFilled += shiftRole.assigned ?? 0;
}
return HomeDashboardData(
weeklySpending: weeklySpending,
next7DaysSpending: next7DaysSpending,
weeklyShifts: weeklyShifts,
next7DaysScheduled: next7DaysScheduled,
totalNeeded: totalNeeded,
totalFilled: totalFilled,
);
}
@override
@@ -114,46 +125,57 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
@override
Future<List<ReorderItem>> getRecentReorders() async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return const <ReorderItem>[];
try {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return const <ReorderItem>[];
}
final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30));
final fdc.Timestamp startTimestamp = _toTimestamp(start);
final fdc.Timestamp endTimestamp = _toTimestamp(now);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result =
await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
).execute();
return result.data.shiftRoles.map((
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
) {
final String location =
shiftRole.shift.location ??
shiftRole.shift.locationAddress ??
'';
final String type = shiftRole.shift.order.orderType.stringValue;
return ReorderItem(
orderId: shiftRole.shift.order.id,
title: '${shiftRole.role.name} - ${shiftRole.shift.title}',
location: location,
hourlyRate: shiftRole.role.costPerHour,
hours: shiftRole.hours ?? 0,
workers: shiftRole.count,
type: type,
);
}).toList();
} catch (e) {
final String error = e.toString().toLowerCase();
if (error.contains('network') ||
error.contains('connection') ||
error.contains('unavailable') ||
error.contains('offline') ||
error.contains('socket') ||
error.contains('failed host lookup')) {
throw NetworkException(technicalMessage: 'Home reorders fetch failed: $e');
}
throw ServerException(technicalMessage: 'Home reorders fetch failed: $e');
}
final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30));
final fdc.Timestamp startTimestamp = _toTimestamp(start);
final fdc.Timestamp endTimestamp = _toTimestamp(now);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result =
await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
).execute();
return result.data.shiftRoles.map((
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
) {
final String location =
shiftRole.shift.location ??
shiftRole.shift.locationAddress ??
'';
final String type = shiftRole.shift.order.orderType.stringValue;
return ReorderItem(
orderId: shiftRole.shift.order.id,
title: '${shiftRole.role.name} - ${shiftRole.shift.title}',
location: location,
hourlyRate: shiftRole.role.costPerHour,
hours: shiftRole.hours ?? 0,
workers: shiftRole.count,
type: type,
);
}).toList();
}
fdc.Timestamp _toTimestamp(DateTime date) {

View File

@@ -10,7 +10,7 @@ import 'client_home_state.dart';
/// BLoC responsible for managing the state and business logic of the client home dashboard.
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
with BlocErrorHandler<ClientHomeState> {
with BlocErrorHandler<ClientHomeState>, SafeBloc<ClientHomeEvent, ClientHomeState> {
ClientHomeBloc({
required GetDashboardDataUseCase getDashboardDataUseCase,
required GetRecentReordersUseCase getRecentReordersUseCase,

View File

@@ -32,8 +32,21 @@ class ClientHomePage extends StatelessWidget {
ClientHomeHeader(i18n: i18n),
ClientHomeEditBanner(i18n: i18n),
Flexible(
child: BlocBuilder<ClientHomeBloc, ClientHomeState>(
child: BlocConsumer<ClientHomeBloc, ClientHomeState>(
listener: (BuildContext context, ClientHomeState state) {
if (state.status == ClientHomeStatus.error &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ClientHomeState state) {
if (state.status == ClientHomeStatus.error) {
return _buildErrorState(context, state);
}
if (state.isEditMode) {
return _buildEditModeList(context, state);
}
@@ -108,4 +121,33 @@ class ClientHomePage extends StatelessWidget {
},
);
}
Widget _buildErrorState(BuildContext context, ClientHomeState state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.error,
size: 48,
color: UiColors.error,
),
const SizedBox(height: UiConstants.space4),
Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
onPressed: () =>
BlocProvider.of<ClientHomeBloc>(context).add(ClientHomeStarted()),
),
],
),
);
}
}

View File

@@ -34,18 +34,21 @@ class ClientHubsPage extends StatelessWidget {
},
listener: (BuildContext context, ClientHubsState state) {
if (state.errorMessage != null && state.errorMessage!.isNotEmpty) {
final String errorMessage = translateErrorKey(state.errorMessage!);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorMessage)),
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsMessageCleared());
}
if (state.successMessage != null && state.successMessage!.isNotEmpty) {
ScaffoldMessenger.of(
UiSnackbar.show(
context,
).showSnackBar(SnackBar(content: Text(state.successMessage!)));
message: state.successMessage!,
type: UiSnackbarType.success,
);
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsMessageCleared());

View File

@@ -52,6 +52,8 @@ class _AddHubDialogState extends State<AddHubDialog> {
super.dispose();
}
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Container(
@@ -68,66 +70,85 @@ class _AddHubDialogState extends State<AddHubDialog> {
BoxShadow(color: UiColors.popupShadow, blurRadius: 20),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
t.client_hubs.add_hub_dialog.title,
style: UiTypography.headline3m.textPrimary,
),
const SizedBox(height: UiConstants.space5),
_buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
TextField(
controller: _nameController,
style: UiTypography.body1r.textPrimary,
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.name_hint,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
t.client_hubs.add_hub_dialog.title,
style: UiTypography.headline3m.textPrimary,
),
),
const SizedBox(height: UiConstants.space4),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.add_hub_dialog.address_hint,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space8),
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: widget.onCancel,
text: t.common.cancel,
),
const SizedBox(height: UiConstants.space5),
_buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
TextFormField(
controller: _nameController,
style: UiTypography.body1r.textPrimary,
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.name_hint,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: UiButton.primary(
onPressed: () {
if (_nameController.text.isNotEmpty) {
widget.onCreate(
_nameController.text,
_addressController.text,
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(
_selectedPrediction?.lat ?? '',
),
longitude: double.tryParse(
_selectedPrediction?.lng ?? '',
),
);
}
},
text: t.client_hubs.add_hub_dialog.create_button,
),
const SizedBox(height: UiConstants.space4),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
// Assuming HubAddressAutocomplete is a custom widget wrapper.
// If it doesn't expose a validator, we might need to modify it or manually check _addressController.
// For now, let's just make sure we validate name. Address is tricky if it's a wrapper.
HubAddressAutocomplete(
controller: _addressController,
hintText: t.client_hubs.add_hub_dialog.address_hint,
focusNode: _addressFocusNode,
onSelected: (Prediction prediction) {
_selectedPrediction = prediction;
},
),
const SizedBox(height: UiConstants.space8),
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
onPressed: widget.onCancel,
text: t.common.cancel,
),
),
),
],
),
],
const SizedBox(width: UiConstants.space3),
Expanded(
child: UiButton.primary(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Manually check address if needed, or assume manual entry is ok.
if (_addressController.text.trim().isEmpty) {
// Show manual error or scaffold
UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error);
return;
}
widget.onCreate(
_nameController.text,
_addressController.text,
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(
_selectedPrediction?.lat ?? '',
),
longitude: double.tryParse(
_selectedPrediction?.lng ?? '',
),
);
}
},
text: t.client_hubs.add_hub_dialog.create_button,
),
),
],
),
],
),
),
),
),

View File

@@ -2,6 +2,8 @@ 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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import '../blocs/client_settings_bloc.dart';
import '../widgets/client_settings_page/settings_actions.dart';
@@ -24,15 +26,19 @@ class ClientSettingsPage extends StatelessWidget {
child: BlocListener<ClientSettingsBloc, ClientSettingsState>(
listener: (BuildContext context, ClientSettingsState state) {
if (state is ClientSettingsSignOutSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Signed out successfully')),
UiSnackbar.show(
context,
message: 'Signed out successfully',
type: UiSnackbarType.success,
);
Modular.to.toClientRoot();
}
if (state is ClientSettingsError) {
ScaffoldMessenger.of(
UiSnackbar.show(
context,
).showSnackBar(SnackBar(content: Text(state.message)));
message: translateErrorKey(state.message),
type: UiSnackbarType.error,
);
}
},
child: const Scaffold(

View File

@@ -62,7 +62,10 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState>
);
_updateDerivedState();
},
onError: (String _) => state.copyWith(status: ViewOrdersStatus.failure),
onError: (String message) => state.copyWith(
status: ViewOrdersStatus.failure,
errorMessage: message,
),
);
}

View File

@@ -15,9 +15,11 @@ class ViewOrdersState extends Equatable {
this.activeCount = 0,
this.completedCount = 0,
this.upNextCount = 0,
this.errorMessage,
});
final ViewOrdersStatus status;
final String? errorMessage;
final List<OrderItem> orders;
final List<OrderItem> filteredOrders;
final List<DateTime> calendarDays;
@@ -39,9 +41,11 @@ class ViewOrdersState extends Equatable {
int? activeCount,
int? completedCount,
int? upNextCount,
String? errorMessage,
}) {
return ViewOrdersState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
orders: orders ?? this.orders,
filteredOrders: filteredOrders ?? this.filteredOrders,
calendarDays: calendarDays ?? this.calendarDays,
@@ -66,5 +70,6 @@ class ViewOrdersState extends Equatable {
activeCount,
completedCount,
upNextCount,
errorMessage,
];
}

View File

@@ -68,7 +68,17 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
@override
Widget build(BuildContext context) {
return BlocBuilder<ViewOrdersCubit, ViewOrdersState>(
return BlocConsumer<ViewOrdersCubit, ViewOrdersState>(
listener: (BuildContext context, ViewOrdersState state) {
if (state.status == ViewOrdersStatus.failure &&
state.errorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ViewOrdersState state) {
final List<DateTime> calendarDays = state.calendarDays;
final List<OrderItem> filteredOrders = state.filteredOrders;
@@ -101,64 +111,66 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
// 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,
),
),
],
),
child: state.status == ViewOrdersStatus.failure
? _buildErrorState(context: context, state: state)
: filteredOrders.isEmpty
? _buildEmptyState(context: context, state: state)
: ListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space4,
UiConstants.space5,
100,
),
...filteredOrders.map(
(OrderItem order) => Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
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),
),
),
child: ViewOrderCard(order: order),
),
],
),
],
),
),
],
),
@@ -208,4 +220,36 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
if (checkDate == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
}
Widget _buildErrorState({
required BuildContext context,
required ViewOrdersState state,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.error,
size: 48,
color: UiColors.error,
),
const SizedBox(height: UiConstants.space4),
Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
onPressed: () => BlocProvider.of<ViewOrdersCubit>(context)
.jumpToDate(state.selectedDate ?? DateTime.now()),
),
],
),
);
}
}