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:
2026-02-20 17:20:06 +05:30
parent e6c4b51e84
commit 8849bf2273
60 changed files with 3804 additions and 2397 deletions

View File

@@ -1,38 +1,30 @@
import 'dart:convert';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:http/http.dart' as http;
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:krow_domain/krow_domain.dart'
show
HubHasOrdersException,
BusinessNotFoundException,
NotAuthenticatedException;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/hub_repository_interface.dart';
/// Implementation of [HubRepositoryInterface] backed by Data Connect.
/// Implementation of [HubRepositoryInterface] that delegates to [dc.HubsConnectorRepository].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
class HubRepositoryImpl implements HubRepositoryInterface {
HubRepositoryImpl({required dc.DataConnectService service})
: _service = service;
final dc.HubsConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
HubRepositoryImpl({
dc.HubsConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getHubsRepository(),
_service = service ?? dc.DataConnectService.instance;
@override
Future<List<domain.Hub>> getHubs() async {
return _service.run(() async {
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
return _fetchHubsForTeam(teamId: teamId, businessId: business.id);
});
Future<List<Hub>> getHubs() async {
final businessId = await _service.getBusinessId();
return _connectorRepository.getHubs(businessId: businessId);
}
@override
Future<domain.Hub> createHub({
Future<Hub> createHub({
required String name,
required String address,
String? placeId,
@@ -44,77 +36,26 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? country,
String? zipCode,
}) async {
return _service.run(() async {
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty
? null
: await _fetchPlaceAddress(placeId);
final String? cityValue = city ?? placeAddress?.city ?? business.city;
final String? stateValue = state ?? placeAddress?.state;
final String? streetValue = street ?? placeAddress?.street;
final String? countryValue = country ?? placeAddress?.country;
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode;
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables>
result = await _service.connector
.createTeamHub(teamId: teamId, hubName: name, address: address)
.placeId(placeId)
.latitude(latitude)
.longitude(longitude)
.city(cityValue?.isNotEmpty == true ? cityValue : '')
.state(stateValue)
.street(streetValue)
.country(countryValue)
.zipCode(zipCodeValue)
.execute();
final String createdId = result.data.teamHub_insert.id;
final List<domain.Hub> hubs = await _fetchHubsForTeam(
teamId: teamId,
businessId: business.id,
);
domain.Hub? createdHub;
for (final domain.Hub hub in hubs) {
if (hub.id == createdId) {
createdHub = hub;
break;
}
}
return createdHub ??
domain.Hub(
id: createdId,
businessId: business.id,
name: name,
address: address,
nfcTagId: null,
status: domain.HubStatus.active,
);
});
final businessId = await _service.getBusinessId();
return _connectorRepository.createHub(
businessId: businessId,
name: name,
address: address,
placeId: placeId,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
street: street,
country: country,
zipCode: zipCode,
);
}
@override
Future<void> deleteHub(String id) async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final QueryResult<
dc.ListOrdersByBusinessAndTeamHubData,
dc.ListOrdersByBusinessAndTeamHubVariables
>
result = await _service.connector
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
.execute();
if (result.data.orders.isNotEmpty) {
throw HubHasOrdersException(
technicalMessage: 'Hub $id has ${result.data.orders.length} orders',
);
}
await _service.connector.deleteTeamHub(id: id).execute();
});
final businessId = await _service.getBusinessId();
return _connectorRepository.deleteHub(businessId: businessId, id: id);
}
@override
@@ -125,7 +66,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
}
@override
Future<domain.Hub> updateHub({
Future<Hub> updateHub({
required String id,
String? name,
String? address,
@@ -138,283 +79,20 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? country,
String? zipCode,
}) async {
return _service.run(() async {
final _PlaceAddress? placeAddress =
placeId == null || placeId.isEmpty
? null
: await _fetchPlaceAddress(placeId);
final dc.UpdateTeamHubVariablesBuilder builder = _service.connector
.updateTeamHub(id: id);
if (name != null) builder.hubName(name);
if (address != null) builder.address(address);
if (placeId != null || placeAddress != null) {
builder.placeId(placeId ?? placeAddress?.street);
}
if (latitude != null) builder.latitude(latitude);
if (longitude != null) builder.longitude(longitude);
if (city != null || placeAddress?.city != null) {
builder.city(city ?? placeAddress?.city);
}
if (state != null || placeAddress?.state != null) {
builder.state(state ?? placeAddress?.state);
}
if (street != null || placeAddress?.street != null) {
builder.street(street ?? placeAddress?.street);
}
if (country != null || placeAddress?.country != null) {
builder.country(country ?? placeAddress?.country);
}
if (zipCode != null || placeAddress?.zipCode != null) {
builder.zipCode(zipCode ?? placeAddress?.zipCode);
}
await builder.execute();
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
final List<domain.Hub> hubs = await _fetchHubsForTeam(
teamId: teamId,
businessId: business.id,
);
for (final domain.Hub hub in hubs) {
if (hub.id == id) return hub;
}
// Fallback: return a reconstructed Hub from the update inputs.
return domain.Hub(
id: id,
businessId: business.id,
name: name ?? '',
address: address ?? '',
nfcTagId: null,
status: domain.HubStatus.active,
);
});
}
Future<dc.GetBusinessesByUserIdBusinesses>
_getBusinessForCurrentUser() async {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
final dc.ClientBusinessSession? cachedBusiness = session?.business;
if (cachedBusiness != null) {
return dc.GetBusinessesByUserIdBusinesses(
id: cachedBusiness.id,
businessName: cachedBusiness.businessName,
userId: _service.auth.currentUser?.uid ?? '',
rateGroup: const dc.Known<dc.BusinessRateGroup>(
dc.BusinessRateGroup.STANDARD,
),
status: const dc.Known<dc.BusinessStatus>(dc.BusinessStatus.ACTIVE),
contactName: cachedBusiness.contactName,
companyLogoUrl: cachedBusiness.companyLogoUrl,
phone: null,
email: cachedBusiness.email,
hubBuilding: null,
address: null,
city: cachedBusiness.city,
area: null,
sector: null,
notes: null,
createdAt: null,
updatedAt: null,
);
}
final firebase.User? user = _service.auth.currentUser;
if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'No Firebase user in currentUser',
);
}
final QueryResult<
dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables
>
result = await _service.connector
.getBusinessesByUserId(userId: user.uid)
.execute();
if (result.data.businesses.isEmpty) {
await _service.auth.signOut();
throw BusinessNotFoundException(
technicalMessage: 'No business found for user ${user.uid}',
);
}
final dc.GetBusinessesByUserIdBusinesses business =
result.data.businesses.first;
if (session != null) {
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
business: dc.ClientBusinessSession(
id: business.id,
businessName: business.businessName,
email: business.email,
city: business.city,
contactName: business.contactName,
companyLogoUrl: business.companyLogoUrl,
),
),
);
}
return business;
}
Future<String> _getOrCreateTeamId(
dc.GetBusinessesByUserIdBusinesses business,
) async {
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables>
teamsResult = await _service.connector
.getTeamsByOwnerId(ownerId: business.id)
.execute();
if (teamsResult.data.teams.isNotEmpty) {
return teamsResult.data.teams.first.id;
}
final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector
.createTeam(
teamName: '${business.businessName} Team',
ownerId: business.id,
ownerName: business.contactName ?? '',
ownerRole: 'OWNER',
);
if (business.email != null) {
createTeamBuilder.email(business.email);
}
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables>
createTeamResult = await createTeamBuilder.execute();
final String teamId = createTeamResult.data.team_insert.id;
return teamId;
}
Future<List<domain.Hub>> _fetchHubsForTeam({
required String teamId,
required String businessId,
}) async {
final QueryResult<
dc.GetTeamHubsByTeamIdData,
dc.GetTeamHubsByTeamIdVariables
>
hubsResult = await _service.connector
.getTeamHubsByTeamId(teamId: teamId)
.execute();
return hubsResult.data.teamHubs
.map(
(dc.GetTeamHubsByTeamIdTeamHubs hub) => domain.Hub(
id: hub.id,
businessId: businessId,
name: hub.hubName,
address: hub.address,
nfcTagId: null,
status: hub.isActive
? domain.HubStatus.active
: domain.HubStatus.inactive,
),
)
.toList();
}
Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async {
final Uri uri = Uri.https(
'maps.googleapis.com',
'/maps/api/place/details/json',
<String, String>{
'place_id': placeId,
'fields': 'address_component',
'key': AppConfig.googleMapsApiKey,
},
final businessId = await _service.getBusinessId();
return _connectorRepository.updateHub(
businessId: businessId,
id: id,
name: name,
address: address,
placeId: placeId,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
street: street,
country: country,
zipCode: zipCode,
);
try {
final http.Response response = await http.get(uri);
if (response.statusCode != 200) {
return null;
}
final Map<String, dynamic> payload =
json.decode(response.body) as Map<String, dynamic>;
if (payload['status'] != 'OK') {
return null;
}
final Map<String, dynamic>? result =
payload['result'] as Map<String, dynamic>?;
final List<dynamic>? components =
result?['address_components'] as List<dynamic>?;
if (components == null || components.isEmpty) {
return null;
}
String? streetNumber;
String? route;
String? city;
String? state;
String? country;
String? zipCode;
for (final dynamic entry in components) {
final Map<String, dynamic> component = entry as Map<String, dynamic>;
final List<dynamic> types =
component['types'] as List<dynamic>? ?? <dynamic>[];
final String? longName = component['long_name'] as String?;
final String? shortName = component['short_name'] as String?;
if (types.contains('street_number')) {
streetNumber = longName;
} else if (types.contains('route')) {
route = longName;
} else if (types.contains('locality')) {
city = longName;
} else if (types.contains('postal_town')) {
city ??= longName;
} else if (types.contains('administrative_area_level_2')) {
city ??= longName;
} else if (types.contains('administrative_area_level_1')) {
state = shortName ?? longName;
} else if (types.contains('country')) {
country = shortName ?? longName;
} else if (types.contains('postal_code')) {
zipCode = longName;
}
}
final String streetValue = <String?>[streetNumber, route]
.where((String? value) => value != null && value.isNotEmpty)
.join(' ')
.trim();
return _PlaceAddress(
street: streetValue.isEmpty == true ? null : streetValue,
city: city,
state: state,
country: country,
zipCode: zipCode,
);
} catch (_) {
return null;
}
}
}
class _PlaceAddress {
const _PlaceAddress({
this.street,
this.city,
this.state,
this.country,
this.zipCode,
});
final String? street;
final String? city;
final String? state;
final String? country;
final String? zipCode;
}