feat: architecture overhaul, launchpad-style reports, and uber-style locations
- Strengthened Buffer Layer architecture to decouple Data Connect from Domain - Rewired Coverage, Performance, and Forecast reports to match Launchpad logic - Implemented Uber-style Preferred Locations search using Google Places API - Added session recovery logic to prevent crashes on app restart - Synchronized backend schemas & SDK for ShiftStatus enums - Fixed various build/compilation errors and localization duplicates
This commit is contained in:
@@ -29,6 +29,8 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
on<PersonalInfoFieldChanged>(_onFieldChanged);
|
||||
on<PersonalInfoAddressSelected>(_onAddressSelected);
|
||||
on<PersonalInfoFormSubmitted>(_onSubmitted);
|
||||
on<PersonalInfoLocationAdded>(_onLocationAdded);
|
||||
on<PersonalInfoLocationRemoved>(_onLocationRemoved);
|
||||
|
||||
add(const PersonalInfoLoadRequested());
|
||||
}
|
||||
@@ -133,11 +135,48 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
PersonalInfoAddressSelected event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
// TODO: Implement Google Places logic if needed
|
||||
// Legacy address selected – no-op; use PersonalInfoLocationAdded instead.
|
||||
}
|
||||
|
||||
/// With _onPhotoUploadRequested and _onSaveRequested removed or renamed,
|
||||
/// there are no errors pointing to them here.
|
||||
/// Adds a location to the preferredLocations list (max 5, no duplicates).
|
||||
void _onLocationAdded(
|
||||
PersonalInfoLocationAdded event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
final dynamic raw = state.formValues['preferredLocations'];
|
||||
final List<String> current = _toStringList(raw);
|
||||
|
||||
if (current.length >= 5) return; // max guard
|
||||
if (current.contains(event.location)) return; // no duplicates
|
||||
|
||||
final List<String> updated = List<String>.from(current)..add(event.location);
|
||||
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
|
||||
..['preferredLocations'] = updated;
|
||||
|
||||
emit(state.copyWith(formValues: updatedValues));
|
||||
}
|
||||
|
||||
/// Removes a location from the preferredLocations list.
|
||||
void _onLocationRemoved(
|
||||
PersonalInfoLocationRemoved event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
final dynamic raw = state.formValues['preferredLocations'];
|
||||
final List<String> current = _toStringList(raw);
|
||||
|
||||
final List<String> updated = List<String>.from(current)
|
||||
..remove(event.location);
|
||||
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
|
||||
..['preferredLocations'] = updated;
|
||||
|
||||
emit(state.copyWith(formValues: updatedValues));
|
||||
}
|
||||
|
||||
List<String> _toStringList(dynamic raw) {
|
||||
if (raw is List<String>) return raw;
|
||||
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
|
||||
return <String>[];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
||||
@@ -40,3 +40,21 @@ class PersonalInfoAddressSelected extends PersonalInfoEvent {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[address];
|
||||
}
|
||||
|
||||
/// Event to add a preferred location.
|
||||
class PersonalInfoLocationAdded extends PersonalInfoEvent {
|
||||
const PersonalInfoLocationAdded({required this.location});
|
||||
final String location;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[location];
|
||||
}
|
||||
|
||||
/// Event to remove a preferred location.
|
||||
class PersonalInfoLocationRemoved extends PersonalInfoEvent {
|
||||
const PersonalInfoLocationRemoved({required this.location});
|
||||
final String location;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[location];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:google_places_flutter/google_places_flutter.dart';
|
||||
import 'package:google_places_flutter/model/prediction.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/personal_info_bloc.dart';
|
||||
import '../blocs/personal_info_event.dart';
|
||||
import '../blocs/personal_info_state.dart';
|
||||
|
||||
/// The maximum number of preferred locations a staff member can add.
|
||||
const int _kMaxLocations = 5;
|
||||
|
||||
/// Uber-style Preferred Locations editing page.
|
||||
///
|
||||
/// Allows staff to search for US locations using the Google Places API,
|
||||
/// add them as chips (max 5), and save back to their profile.
|
||||
class PreferredLocationsPage extends StatefulWidget {
|
||||
/// Creates a [PreferredLocationsPage].
|
||||
const PreferredLocationsPage({super.key});
|
||||
|
||||
@override
|
||||
State<PreferredLocationsPage> createState() => _PreferredLocationsPageState();
|
||||
}
|
||||
|
||||
class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
|
||||
late final TextEditingController _searchController;
|
||||
late final FocusNode _searchFocusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController();
|
||||
_searchFocusNode = FocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLocationSelected(Prediction prediction, PersonalInfoBloc bloc) {
|
||||
final String description = prediction.description ?? '';
|
||||
if (description.isEmpty) return;
|
||||
|
||||
bloc.add(PersonalInfoLocationAdded(location: description));
|
||||
|
||||
// Clear search field after selection
|
||||
_searchController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
}
|
||||
|
||||
void _removeLocation(String location, PersonalInfoBloc bloc) {
|
||||
bloc.add(PersonalInfoLocationRemoved(location: location));
|
||||
}
|
||||
|
||||
void _save(BuildContext context, PersonalInfoBloc bloc, PersonalInfoState state) {
|
||||
bloc.add(const PersonalInfoFormSubmitted());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.staff.onboarding.personal_info;
|
||||
// Access the same PersonalInfoBloc singleton managed by the module.
|
||||
final PersonalInfoBloc bloc = Modular.get<PersonalInfoBloc>();
|
||||
|
||||
return BlocProvider<PersonalInfoBloc>.value(
|
||||
value: bloc,
|
||||
child: BlocConsumer<PersonalInfoBloc, PersonalInfoState>(
|
||||
listener: (BuildContext context, PersonalInfoState state) {
|
||||
if (state.status == PersonalInfoStatus.saved) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: i18n.preferred_locations.save_success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
} else if (state.status == PersonalInfoStatus.error) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage != null
|
||||
? translateErrorKey(state.errorMessage!)
|
||||
: 'An error occurred',
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, PersonalInfoState state) {
|
||||
final List<String> locations = _currentLocations(state);
|
||||
final bool atMax = locations.length >= _kMaxLocations;
|
||||
final bool isSaving = state.status == PersonalInfoStatus.saving;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: UiColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: UiColors.bgPopup,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
),
|
||||
title: Text(
|
||||
i18n.preferred_locations.title,
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: Container(color: UiColors.border, height: 1.0),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// ── Description
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
UiConstants.space3,
|
||||
),
|
||||
child: Text(
|
||||
i18n.preferred_locations.description,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Search autocomplete field
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: _PlacesSearchField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
hint: i18n.preferred_locations.search_hint,
|
||||
enabled: !atMax && !isSaving,
|
||||
onSelected: (Prediction p) => _onLocationSelected(p, bloc),
|
||||
),
|
||||
),
|
||||
|
||||
// ── "Max reached" banner
|
||||
if (atMax)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
UiConstants.space2,
|
||||
UiConstants.space5,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.info,
|
||||
size: 14,
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
i18n.preferred_locations.max_reached,
|
||||
style: UiTypography.footnote1r.textWarning,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
|
||||
// ── Section label
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Text(
|
||||
i18n.preferred_locations.added_label,
|
||||
style: UiTypography.titleUppercase3m.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// ── Locations list / empty state
|
||||
Expanded(
|
||||
child: locations.isEmpty
|
||||
? _EmptyLocationsState(message: i18n.preferred_locations.empty_state)
|
||||
: _LocationsList(
|
||||
locations: locations,
|
||||
isSaving: isSaving,
|
||||
removeTooltip: i18n.preferred_locations.remove_tooltip,
|
||||
onRemove: (String loc) => _removeLocation(loc, bloc),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Save button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: UiButton.primary(
|
||||
text: i18n.preferred_locations.save_button,
|
||||
fullWidth: true,
|
||||
onPressed: isSaving ? null : () => _save(context, bloc, state),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _currentLocations(PersonalInfoState state) {
|
||||
final dynamic raw = state.formValues['preferredLocations'];
|
||||
if (raw is List<String>) return raw;
|
||||
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
|
||||
return <String>[];
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Subwidgets
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Google Places autocomplete search field, locked to US results.
|
||||
class _PlacesSearchField extends StatelessWidget {
|
||||
const _PlacesSearchField({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.hint,
|
||||
required this.onSelected,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final String hint;
|
||||
final bool enabled;
|
||||
final void Function(Prediction) onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GooglePlaceAutoCompleteTextField(
|
||||
textEditingController: controller,
|
||||
focusNode: focusNode,
|
||||
googleAPIKey: AppConfig.googleMapsApiKey,
|
||||
debounceTime: 400,
|
||||
countries: const <String>['us'],
|
||||
isLatLngRequired: false,
|
||||
getPlaceDetailWithLatLng: onSelected,
|
||||
itemClick: (Prediction prediction) {
|
||||
controller.text = prediction.description ?? '';
|
||||
controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: controller.text.length),
|
||||
);
|
||||
onSelected(prediction);
|
||||
},
|
||||
inputDecoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: UiTypography.body2r.textSecondary,
|
||||
prefixIcon: const Icon(UiIcons.search, color: UiColors.iconSecondary, size: 20),
|
||||
suffixIcon: controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(UiIcons.close, size: 18, color: UiColors.iconSecondary),
|
||||
onPressed: controller.clear,
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: const BorderSide(color: UiColors.primary, width: 1.5),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
|
||||
filled: true,
|
||||
),
|
||||
textStyle: UiTypography.body2r.textPrimary,
|
||||
itemBuilder: (BuildContext context, int index, Prediction prediction) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space2,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: const Icon(UiIcons.mapPin, size: 16, color: UiColors.primary),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
_mainText(prediction.description ?? ''),
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_subText(prediction.description ?? '').isNotEmpty)
|
||||
Text(
|
||||
_subText(prediction.description ?? ''),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts text before first comma as the primary line.
|
||||
String _mainText(String description) {
|
||||
final int commaIndex = description.indexOf(',');
|
||||
return commaIndex > 0 ? description.substring(0, commaIndex) : description;
|
||||
}
|
||||
|
||||
/// Extracts text after first comma as the secondary line.
|
||||
String _subText(String description) {
|
||||
final int commaIndex = description.indexOf(',');
|
||||
return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : '';
|
||||
}
|
||||
}
|
||||
|
||||
/// The scrollable list of location chips.
|
||||
class _LocationsList extends StatelessWidget {
|
||||
const _LocationsList({
|
||||
required this.locations,
|
||||
required this.isSaving,
|
||||
required this.removeTooltip,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final List<String> locations;
|
||||
final bool isSaving;
|
||||
final String removeTooltip;
|
||||
final void Function(String) onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
itemCount: locations.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: UiConstants.space2),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final String location = locations[index];
|
||||
return _LocationChip(
|
||||
label: location,
|
||||
index: index + 1,
|
||||
total: locations.length,
|
||||
isSaving: isSaving,
|
||||
removeTooltip: removeTooltip,
|
||||
onRemove: () => onRemove(location),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single location row with pin icon, label, and remove button.
|
||||
class _LocationChip extends StatelessWidget {
|
||||
const _LocationChip({
|
||||
required this.label,
|
||||
required this.index,
|
||||
required this.total,
|
||||
required this.isSaving,
|
||||
required this.removeTooltip,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int index;
|
||||
final int total;
|
||||
final bool isSaving;
|
||||
final String removeTooltip;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
// Index badge
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$index',
|
||||
style: UiTypography.footnote1m.copyWith(color: UiColors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
|
||||
// Pin icon
|
||||
const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
|
||||
// Location text
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Remove button
|
||||
if (!isSaving)
|
||||
Tooltip(
|
||||
message: removeTooltip,
|
||||
child: GestureDetector(
|
||||
onTap: onRemove,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space1),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(UiIcons.close, size: 14, color: UiColors.iconSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows when no locations have been added yet.
|
||||
class _EmptyLocationsState extends StatelessWidget {
|
||||
const _EmptyLocationsState({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,26 +34,22 @@ class PersonalInfoContent extends StatefulWidget {
|
||||
class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _phoneController;
|
||||
late final TextEditingController _locationsController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_emailController = TextEditingController(text: widget.staff.email);
|
||||
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
|
||||
_locationsController = TextEditingController(text: widget.staff.preferredLocations?.join(', ')?? '');
|
||||
|
||||
// Listen to changes and update BLoC
|
||||
_emailController.addListener(_onEmailChanged);
|
||||
_phoneController.addListener(_onPhoneChanged);
|
||||
_locationsController.addListener(_onAddressChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_locationsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -76,23 +72,6 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onAddressChanged() {
|
||||
// Split the comma-separated string into a list for storage
|
||||
// The backend expects List<AnyValue> (JSON/List) for preferredLocations
|
||||
final List<String> locations = _locationsController.text
|
||||
.split(',')
|
||||
.map((String e) => e.trim())
|
||||
.where((String e) => e.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
context.read<PersonalInfoBloc>().add(
|
||||
PersonalInfoFieldChanged(
|
||||
field: 'preferredLocations',
|
||||
value: locations,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSave() {
|
||||
context.read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
|
||||
}
|
||||
@@ -129,7 +108,7 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
email: widget.staff.email,
|
||||
emailController: _emailController,
|
||||
phoneController: _phoneController,
|
||||
locationsController: _locationsController,
|
||||
currentLocations: _toStringList(state.formValues['preferredLocations']),
|
||||
enabled: !isSaving,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space16), // Space for bottom button
|
||||
@@ -147,4 +126,10 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _toStringList(dynamic raw) {
|
||||
if (raw is List<String>) return raw;
|
||||
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
|
||||
return <String>[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
|
||||
/// A form widget containing all personal information fields.
|
||||
///
|
||||
/// Includes read-only fields for full name and email,
|
||||
/// and editable fields for phone and address.
|
||||
/// Includes read-only fields for full name,
|
||||
/// and editable fields for email and phone.
|
||||
/// The Preferred Locations row navigates to a dedicated Uber-style page.
|
||||
/// Uses only design system tokens for colors, typography, and spacing.
|
||||
class PersonalInfoForm extends StatelessWidget {
|
||||
|
||||
@@ -19,7 +19,7 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
required this.email,
|
||||
required this.emailController,
|
||||
required this.phoneController,
|
||||
required this.locationsController,
|
||||
required this.currentLocations,
|
||||
this.enabled = true,
|
||||
});
|
||||
/// The staff member's full name (read-only).
|
||||
@@ -34,8 +34,8 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
/// Controller for the phone number field.
|
||||
final TextEditingController phoneController;
|
||||
|
||||
/// Controller for the address field.
|
||||
final TextEditingController locationsController;
|
||||
/// Current preferred locations list to show in the summary row.
|
||||
final List<String> currentLocations;
|
||||
|
||||
/// Whether the form fields are enabled for editing.
|
||||
final bool enabled;
|
||||
@@ -43,6 +43,9 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
|
||||
final String locationSummary = currentLocations.isEmpty
|
||||
? i18n.locations_summary_none
|
||||
: currentLocations.join(', ');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -69,15 +72,21 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
controller: phoneController,
|
||||
hint: i18n.phone_hint,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
_FieldLabel(text: i18n.locations_label),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_EditableField(
|
||||
controller: locationsController,
|
||||
// Uber-style tappable row → navigates to PreferredLocationsPage
|
||||
_TappableRow(
|
||||
value: locationSummary,
|
||||
hint: i18n.locations_hint,
|
||||
icon: UiIcons.mapPin,
|
||||
enabled: enabled,
|
||||
onTap: enabled
|
||||
? () => Modular.to.pushNamed(StaffPaths.preferredLocations)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
@@ -91,6 +100,68 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// An Uber-style tappable row for navigating to a sub-page editor.
|
||||
/// Displays the current value (or hint if empty) and a chevron arrow.
|
||||
class _TappableRow extends StatelessWidget {
|
||||
const _TappableRow({
|
||||
required this.value,
|
||||
required this.hint,
|
||||
required this.icon,
|
||||
this.onTap,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final String hint;
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasValue = value.isNotEmpty;
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
border: Border.all(
|
||||
color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 18, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasValue ? value : hint,
|
||||
style: hasValue
|
||||
? UiTypography.body2r.textPrimary
|
||||
: UiTypography.body2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (enabled)
|
||||
Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A language selector widget that displays the current language and navigates to language selection page.
|
||||
class _LanguageSelector extends StatelessWidget {
|
||||
const _LanguageSelector({
|
||||
@@ -99,46 +170,43 @@ class _LanguageSelector extends StatelessWidget {
|
||||
|
||||
final bool enabled;
|
||||
|
||||
String _getLanguageLabel(AppLocale locale) {
|
||||
switch (locale) {
|
||||
case AppLocale.en:
|
||||
return 'English';
|
||||
case AppLocale.es:
|
||||
return 'Español';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AppLocale currentLocale = LocaleSettings.currentLocale;
|
||||
final String currentLanguage = _getLanguageLabel(currentLocale);
|
||||
final String currentLocale = Localizations.localeOf(context).languageCode;
|
||||
final String languageName = currentLocale == 'es' ? 'Español' : 'English';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: enabled
|
||||
? () => Modular.to.pushNamed(StaffPaths.languageSelection)
|
||||
: null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
color: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
border: Border.all(color: UiColors.border),
|
||||
border: Border.all(
|
||||
color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
currentLanguage,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
Icon(
|
||||
UiIcons.chevronRight,
|
||||
color: UiColors.textSecondary,
|
||||
const Icon(UiIcons.settings, size: 18, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
languageName,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
),
|
||||
if (enabled)
|
||||
const Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -146,10 +214,7 @@ class _LanguageSelector extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A label widget for form fields.
|
||||
/// A label widget for form fields.
|
||||
class _FieldLabel extends StatelessWidget {
|
||||
|
||||
const _FieldLabel({required this.text});
|
||||
final String text;
|
||||
|
||||
@@ -157,13 +222,11 @@ class _FieldLabel extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
style: UiTypography.titleUppercase3m.textSecondary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only field widget for displaying non-editable information.
|
||||
/// A read-only field widget for displaying non-editable information.
|
||||
class _ReadOnlyField extends StatelessWidget {
|
||||
const _ReadOnlyField({required this.value});
|
||||
final String value;
|
||||
@@ -183,14 +246,12 @@ class _ReadOnlyField extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
style: UiTypography.body2r.textInactive,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An editable text field widget.
|
||||
/// An editable text field widget.
|
||||
class _EditableField extends StatelessWidget {
|
||||
const _EditableField({
|
||||
required this.controller,
|
||||
@@ -232,7 +293,7 @@ class _EditableField extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: const BorderSide(color: UiColors.primary),
|
||||
),
|
||||
fillColor: UiColors.bgPopup,
|
||||
fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
|
||||
filled: true,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'domain/usecases/update_personal_info_usecase.dart';
|
||||
import 'presentation/blocs/personal_info_bloc.dart';
|
||||
import 'presentation/pages/personal_info_page.dart';
|
||||
import 'presentation/pages/language_selection_page.dart';
|
||||
import 'presentation/pages/preferred_locations_page.dart';
|
||||
|
||||
/// The entry module for the Staff Profile Info feature.
|
||||
///
|
||||
@@ -61,5 +62,12 @@ class StaffProfileInfoModule extends Module {
|
||||
),
|
||||
child: (BuildContext context) => const LanguageSelectionPage(),
|
||||
);
|
||||
r.child(
|
||||
StaffPaths.childRoute(
|
||||
StaffPaths.onboardingPersonalInfo,
|
||||
StaffPaths.preferredLocations,
|
||||
),
|
||||
child: (BuildContext context) => const PreferredLocationsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ dependencies:
|
||||
|
||||
firebase_auth: any
|
||||
firebase_data_connect: any
|
||||
google_places_flutter: ^2.1.1
|
||||
http: ^1.2.2
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,371 +1,70 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import '../../domain/repositories/shifts_repository_interface.dart';
|
||||
|
||||
class ShiftsRepositoryImpl
|
||||
implements ShiftsRepositoryInterface {
|
||||
/// Implementation of [ShiftsRepositoryInterface] that delegates to [dc.ShiftsConnectorRepository].
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
final dc.ShiftsConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
ShiftsRepositoryImpl() : _service = dc.DataConnectService.instance;
|
||||
ShiftsRepositoryImpl({
|
||||
dc.ShiftsConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getShiftsRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
// Cache: ShiftID -> ApplicationID (For Accept/Decline)
|
||||
final Map<String, String> _shiftToAppIdMap = {};
|
||||
// Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation)
|
||||
final Map<String, String> _appToRoleIdMap = {};
|
||||
|
||||
// This need to be an APPLICATION
|
||||
// THERE SHOULD BE APPLICATIONSTATUS and SHIFTSTATUS enums in the domain layer to avoid this string mapping and potential bugs.
|
||||
@override
|
||||
Future<List<Shift>> getMyShifts({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async {
|
||||
return _fetchApplications(start: start, end: end);
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getMyShifts(
|
||||
staffId: staffId,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getPendingAssignments() async {
|
||||
return <Shift>[];
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getPendingAssignments(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getCancelledShifts() async {
|
||||
return <Shift>[];
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getCancelledShifts(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getHistoryShifts() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
final fdc.QueryResult<dc.ListCompletedApplicationsByStaffIdData, dc.ListCompletedApplicationsByStaffIdVariables> response = await _service.executeProtected(() => _service.connector
|
||||
.listCompletedApplicationsByStaffId(staffId: staffId)
|
||||
.execute());
|
||||
final List<Shift> shifts = [];
|
||||
|
||||
for (final app in response.data.applications) {
|
||||
_shiftToAppIdMap[app.shift.id] = app.id;
|
||||
_appToRoleIdMap[app.id] = app.shiftRole.id;
|
||||
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName =
|
||||
(app.shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? app.shift.order.eventName!
|
||||
: app.shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
shifts.add(
|
||||
Shift(
|
||||
id: app.shift.id,
|
||||
roleId: app.shiftRole.roleId,
|
||||
title: title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
logoUrl: app.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: app.shift.location ?? '',
|
||||
locationAddress: app.shift.order.teamHub.hubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: _mapStatus(dc.ApplicationStatus.CHECKED_OUT),
|
||||
description: app.shift.description,
|
||||
durationDays: app.shift.durationDays,
|
||||
requiredSlots: app.shiftRole.count,
|
||||
filledSlots: app.shiftRole.assigned ?? 0,
|
||||
hasApplied: true,
|
||||
latitude: app.shift.latitude,
|
||||
longitude: app.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: app.shiftRole.isBreakPaid ?? false,
|
||||
breakTime: app.shiftRole.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return shifts;
|
||||
}
|
||||
|
||||
Future<List<Shift>> _fetchApplications({
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
}) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
var query = _service.connector.getApplicationsByStaffId(staffId: staffId);
|
||||
if (start != null && end != null) {
|
||||
query = query.dayStart(_service.toTimestamp(start)).dayEnd(_service.toTimestamp(end));
|
||||
}
|
||||
final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> response = await _service.executeProtected(() => query.execute());
|
||||
|
||||
final apps = response.data.applications;
|
||||
final List<Shift> shifts = [];
|
||||
|
||||
for (final app in apps) {
|
||||
_shiftToAppIdMap[app.shift.id] = app.id;
|
||||
_appToRoleIdMap[app.id] = app.shiftRole.id;
|
||||
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName =
|
||||
(app.shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? app.shift.order.eventName!
|
||||
: app.shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
// Override status to reflect the application state (e.g., CHECKED_OUT, CONFIRMED)
|
||||
final bool hasCheckIn = app.checkInTime != null;
|
||||
final bool hasCheckOut = app.checkOutTime != null;
|
||||
dc.ApplicationStatus? appStatus;
|
||||
if (app.status is dc.Known<dc.ApplicationStatus>) {
|
||||
appStatus = (app.status as dc.Known<dc.ApplicationStatus>).value;
|
||||
}
|
||||
final String mappedStatus = hasCheckOut
|
||||
? 'completed'
|
||||
: hasCheckIn
|
||||
? 'checked_in'
|
||||
: _mapStatus(appStatus ?? dc.ApplicationStatus.CONFIRMED);
|
||||
shifts.add(
|
||||
Shift(
|
||||
id: app.shift.id,
|
||||
roleId: app.shiftRole.roleId,
|
||||
title: title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
logoUrl: app.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: app.shift.location ?? '',
|
||||
locationAddress: app.shift.order.teamHub.hubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: mappedStatus,
|
||||
description: app.shift.description,
|
||||
durationDays: app.shift.durationDays,
|
||||
requiredSlots: app.shiftRole.count,
|
||||
filledSlots: app.shiftRole.assigned ?? 0,
|
||||
hasApplied: true,
|
||||
latitude: app.shift.latitude,
|
||||
longitude: app.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: app.shiftRole.isBreakPaid ?? false,
|
||||
breakTime: app.shiftRole.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return shifts;
|
||||
}
|
||||
|
||||
String _mapStatus(dc.ApplicationStatus status) {
|
||||
switch (status) {
|
||||
case dc.ApplicationStatus.CONFIRMED:
|
||||
return 'confirmed';
|
||||
case dc.ApplicationStatus.PENDING:
|
||||
return 'pending';
|
||||
case dc.ApplicationStatus.CHECKED_OUT:
|
||||
return 'completed';
|
||||
case dc.ApplicationStatus.REJECTED:
|
||||
return 'cancelled';
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
return _connectorRepository.getHistoryShifts(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getAvailableShifts(String query, String type) async {
|
||||
final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId;
|
||||
if (vendorId == null || vendorId.isEmpty) {
|
||||
return <Shift>[];
|
||||
}
|
||||
|
||||
final fdc.QueryResult<dc.ListShiftRolesByVendorIdData, dc.ListShiftRolesByVendorIdVariables> result = await _service.executeProtected(() => _service.connector
|
||||
.listShiftRolesByVendorId(vendorId: vendorId)
|
||||
.execute());
|
||||
|
||||
final allShiftRoles = result.data.shiftRoles;
|
||||
|
||||
// Fetch my applications to filter out already booked shifts
|
||||
final List<Shift> myShifts = await _fetchApplications();
|
||||
final Set<String> myShiftIds = myShifts.map((s) => s.id).toSet();
|
||||
|
||||
final List<Shift> mappedShifts = [];
|
||||
for (final sr in allShiftRoles) {
|
||||
// Skip if I have already applied/booked this shift
|
||||
if (myShiftIds.contains(sr.shiftId)) continue;
|
||||
|
||||
|
||||
final DateTime? shiftDate = _service.toDateTime(sr.shift.date);
|
||||
final startDt = _service.toDateTime(sr.startTime);
|
||||
final endDt = _service.toDateTime(sr.endTime);
|
||||
final createdDt = _service.toDateTime(sr.createdAt);
|
||||
|
||||
mappedShifts.add(
|
||||
Shift(
|
||||
id: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.role.name,
|
||||
clientName: sr.shift.order.business.businessName,
|
||||
logoUrl: null,
|
||||
hourlyRate: sr.role.costPerHour,
|
||||
location: sr.shift.location ?? '',
|
||||
locationAddress: sr.shift.locationAddress ?? '',
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null
|
||||
? DateFormat('HH:mm').format(startDt)
|
||||
: '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: sr.shift.status?.stringValue.toLowerCase() ?? 'open',
|
||||
description: sr.shift.description,
|
||||
durationDays: sr.shift.durationDays,
|
||||
requiredSlots: sr.count,
|
||||
filledSlots: sr.assigned ?? 0,
|
||||
latitude: sr.shift.latitude,
|
||||
longitude: sr.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: sr.isBreakPaid ?? false,
|
||||
breakTime: sr.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isNotEmpty) {
|
||||
return mappedShifts
|
||||
.where(
|
||||
(s) =>
|
||||
s.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
s.clientName.toLowerCase().contains(query.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return mappedShifts;
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getAvailableShifts(
|
||||
staffId: staffId,
|
||||
query: query,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Shift?> getShiftDetails(String shiftId, {String? roleId}) async {
|
||||
return _getShiftDetails(shiftId, roleId: roleId);
|
||||
}
|
||||
|
||||
Future<Shift?> _getShiftDetails(String shiftId, {String? roleId}) async {
|
||||
if (roleId != null && roleId.isNotEmpty) {
|
||||
final roleResult = await _service.executeProtected(() => _service.connector
|
||||
.getShiftRoleById(shiftId: shiftId, roleId: roleId)
|
||||
.execute());
|
||||
final sr = roleResult.data.shiftRole;
|
||||
if (sr == null) return null;
|
||||
|
||||
final DateTime? startDt = _service.toDateTime(sr.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(sr.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
|
||||
|
||||
final String staffId = await _service.getStaffId();
|
||||
bool hasApplied = false;
|
||||
String status = 'open';
|
||||
final apps = await _service.executeProtected(() =>
|
||||
_service.connector.getApplicationsByStaffId(staffId: staffId).execute());
|
||||
final app = apps.data.applications
|
||||
.where(
|
||||
(a) => a.shiftId == shiftId && a.shiftRole.roleId == roleId,
|
||||
)
|
||||
.firstOrNull;
|
||||
if (app != null) {
|
||||
hasApplied = true;
|
||||
if (app.status is dc.Known<dc.ApplicationStatus>) {
|
||||
final dc.ApplicationStatus s =
|
||||
(app.status as dc.Known<dc.ApplicationStatus>).value;
|
||||
status = _mapStatus(s);
|
||||
}
|
||||
}
|
||||
|
||||
return Shift(
|
||||
id: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.shift.order.business.businessName,
|
||||
clientName: sr.shift.order.business.businessName,
|
||||
logoUrl: sr.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: sr.role.costPerHour,
|
||||
location: sr.shift.location ?? sr.shift.order.teamHub.hubName,
|
||||
locationAddress: sr.shift.locationAddress ?? '',
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: status,
|
||||
description: sr.shift.description,
|
||||
durationDays: null,
|
||||
requiredSlots: sr.count,
|
||||
filledSlots: sr.assigned ?? 0,
|
||||
hasApplied: hasApplied,
|
||||
totalValue: sr.totalValue,
|
||||
latitude: sr.shift.latitude,
|
||||
longitude: sr.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: sr.isBreakPaid ?? false,
|
||||
breakTime: sr.breakType?.stringValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final fdc.QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables> result =
|
||||
await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute());
|
||||
final s = result.data.shift;
|
||||
if (s == null) return null;
|
||||
|
||||
int? required;
|
||||
int? filled;
|
||||
Break? breakInfo;
|
||||
try {
|
||||
final rolesRes = await _service.executeProtected(() =>
|
||||
_service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute());
|
||||
if (rolesRes.data.shiftRoles.isNotEmpty) {
|
||||
required = 0;
|
||||
filled = 0;
|
||||
for (var r in rolesRes.data.shiftRoles) {
|
||||
required = (required ?? 0) + r.count;
|
||||
filled = (filled ?? 0) + (r.assigned ?? 0);
|
||||
}
|
||||
// Use the first role's break info as a representative
|
||||
final firstRole = rolesRes.data.shiftRoles.first;
|
||||
breakInfo = BreakAdapter.fromData(
|
||||
isPaid: firstRole.isBreakPaid ?? false,
|
||||
breakTime: firstRole.breakType?.stringValue,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final startDt = _service.toDateTime(s.startTime);
|
||||
final endDt = _service.toDateTime(s.endTime);
|
||||
final createdDt = _service.toDateTime(s.createdAt);
|
||||
|
||||
return Shift(
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
clientName: s.order.business.businessName,
|
||||
logoUrl: null,
|
||||
hourlyRate: s.cost ?? 0.0,
|
||||
location: s.location ?? '',
|
||||
locationAddress: s.locationAddress ?? '',
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: s.status?.stringValue ?? 'OPEN',
|
||||
description: s.description,
|
||||
durationDays: s.durationDays,
|
||||
requiredSlots: required,
|
||||
filledSlots: filled,
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
breakInfo: breakInfo,
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getShiftDetails(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: roleId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,182 +75,29 @@ class ShiftsRepositoryImpl
|
||||
String? roleId,
|
||||
}) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
|
||||
String targetRoleId = roleId ?? '';
|
||||
if (targetRoleId.isEmpty) {
|
||||
throw Exception('Missing role id.');
|
||||
}
|
||||
|
||||
final roleResult = await _service.executeProtected(() => _service.connector
|
||||
.getShiftRoleById(shiftId: shiftId, roleId: targetRoleId)
|
||||
.execute());
|
||||
final role = roleResult.data.shiftRole;
|
||||
if (role == null) {
|
||||
throw Exception('Shift role not found');
|
||||
}
|
||||
final shiftResult =
|
||||
await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute());
|
||||
final shift = shiftResult.data.shift;
|
||||
if (shift == null) {
|
||||
throw Exception('Shift not found');
|
||||
}
|
||||
final DateTime? shiftDate = _service.toDateTime(shift.date);
|
||||
if (shiftDate != null) {
|
||||
final DateTime dayStartUtc = DateTime.utc(
|
||||
shiftDate.year,
|
||||
shiftDate.month,
|
||||
shiftDate.day,
|
||||
);
|
||||
final DateTime dayEndUtc = DateTime.utc(
|
||||
shiftDate.year,
|
||||
shiftDate.month,
|
||||
shiftDate.day,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
999,
|
||||
);
|
||||
|
||||
final dayApplications = await _service.executeProtected(() => _service.connector
|
||||
.vaidateDayStaffApplication(staffId: staffId)
|
||||
.dayStart(_service.toTimestamp(dayStartUtc))
|
||||
.dayEnd(_service.toTimestamp(dayEndUtc))
|
||||
.execute());
|
||||
if (dayApplications.data.applications.isNotEmpty) {
|
||||
throw Exception('The user already has a shift that day.');
|
||||
}
|
||||
}
|
||||
final existingApplicationResult = await _service.executeProtected(() => _service.connector
|
||||
.getApplicationByStaffShiftAndRole(
|
||||
staffId: staffId,
|
||||
shiftId: shiftId,
|
||||
roleId: targetRoleId,
|
||||
)
|
||||
.execute());
|
||||
if (existingApplicationResult.data.applications.isNotEmpty) {
|
||||
throw Exception('Application already exists.');
|
||||
}
|
||||
final int assigned = role.assigned ?? 0;
|
||||
if (assigned >= role.count) {
|
||||
throw Exception('This shift is full.');
|
||||
}
|
||||
|
||||
final int filled = shift.filled ?? 0;
|
||||
|
||||
String? appId;
|
||||
bool updatedRole = false;
|
||||
bool updatedShift = false;
|
||||
try {
|
||||
final appResult = await _service.executeProtected(() => _service.connector
|
||||
.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: targetRoleId,
|
||||
status: dc.ApplicationStatus.CONFIRMED,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
)
|
||||
// TODO: this should be PENDING so a vendor can accept it.
|
||||
.execute());
|
||||
appId = appResult.data.application_insert.id;
|
||||
|
||||
await _service.executeProtected(() => _service.connector
|
||||
.updateShiftRole(shiftId: shiftId, roleId: targetRoleId)
|
||||
.assigned(assigned + 1)
|
||||
.execute());
|
||||
updatedRole = true;
|
||||
|
||||
await _service.executeProtected(
|
||||
() => _service.connector.updateShift(id: shiftId).filled(filled + 1).execute());
|
||||
updatedShift = true;
|
||||
} catch (e) {
|
||||
if (updatedShift) {
|
||||
try {
|
||||
await _service.connector.updateShift(id: shiftId).filled(filled).execute();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (updatedRole) {
|
||||
try {
|
||||
await _service.connector
|
||||
.updateShiftRole(shiftId: shiftId, roleId: targetRoleId)
|
||||
.assigned(assigned)
|
||||
.execute();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (appId != null) {
|
||||
try {
|
||||
await _service.connector.deleteApplication(id: appId).execute();
|
||||
} catch (_) {}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
return _connectorRepository.applyForShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
isInstantBook: isInstantBook,
|
||||
roleId: roleId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> acceptShift(String shiftId) async {
|
||||
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.CONFIRMED);
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.acceptShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> declineShift(String shiftId) async {
|
||||
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.REJECTED);
|
||||
}
|
||||
|
||||
Future<void> _updateApplicationStatus(
|
||||
String shiftId,
|
||||
dc.ApplicationStatus newStatus,
|
||||
) async {
|
||||
String? appId = _shiftToAppIdMap[shiftId];
|
||||
String? roleId;
|
||||
|
||||
if (appId == null) {
|
||||
// Try to find it in pending
|
||||
await getPendingAssignments();
|
||||
}
|
||||
// Re-check map
|
||||
appId = _shiftToAppIdMap[shiftId];
|
||||
if (appId != null) {
|
||||
roleId = _appToRoleIdMap[appId];
|
||||
} else {
|
||||
// Fallback fetch
|
||||
final staffId = await _service.getStaffId();
|
||||
final apps = await _service.executeProtected(() =>
|
||||
_service.connector.getApplicationsByStaffId(staffId: staffId).execute());
|
||||
final app = apps.data.applications
|
||||
.where((a) => a.shiftId == shiftId)
|
||||
.firstOrNull;
|
||||
if (app != null) {
|
||||
appId = app.id;
|
||||
roleId = app.shiftRole.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (appId == null || roleId == null) {
|
||||
// If we are rejecting and can't find an application, create one as rejected (declining an available shift)
|
||||
if (newStatus == dc.ApplicationStatus.REJECTED) {
|
||||
final rolesResult = await _service.executeProtected(() =>
|
||||
_service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute());
|
||||
if (rolesResult.data.shiftRoles.isNotEmpty) {
|
||||
final role = rolesResult.data.shiftRoles.first;
|
||||
final staffId = await _service.getStaffId();
|
||||
await _service.executeProtected(() => _service.connector
|
||||
.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: role.id,
|
||||
status: dc.ApplicationStatus.REJECTED,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
)
|
||||
.execute());
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw Exception("Application not found for shift $shiftId");
|
||||
}
|
||||
|
||||
await _service.executeProtected(() => _service.connector
|
||||
.updateApplicationStatus(id: appId!)
|
||||
.status(newStatus)
|
||||
.execute());
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.declineShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user