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:
@@ -1,12 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import '../../krow_data_connect.dart' as dc;
|
||||
import '../connectors/reports/domain/repositories/reports_connector_repository.dart';
|
||||
import '../connectors/reports/data/repositories/reports_connector_repository_impl.dart';
|
||||
import '../connectors/shifts/domain/repositories/shifts_connector_repository.dart';
|
||||
import '../connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
|
||||
import '../connectors/hubs/domain/repositories/hubs_connector_repository.dart';
|
||||
import '../connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
|
||||
import '../connectors/billing/domain/repositories/billing_connector_repository.dart';
|
||||
import '../connectors/billing/data/repositories/billing_connector_repository_impl.dart';
|
||||
import '../connectors/home/domain/repositories/home_connector_repository.dart';
|
||||
import '../connectors/home/data/repositories/home_connector_repository_impl.dart';
|
||||
import '../connectors/coverage/domain/repositories/coverage_connector_repository.dart';
|
||||
import '../connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
|
||||
import '../connectors/staff/domain/repositories/staff_connector_repository.dart';
|
||||
import '../connectors/staff/data/repositories/staff_connector_repository_impl.dart';
|
||||
import 'mixins/data_error_handler.dart';
|
||||
import 'mixins/session_handler_mixin.dart';
|
||||
|
||||
@@ -22,176 +33,203 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin {
|
||||
/// The Data Connect connector used for data operations.
|
||||
final dc.ExampleConnector connector = dc.ExampleConnector.instance;
|
||||
|
||||
/// The Firebase Auth instance.
|
||||
firebase_auth.FirebaseAuth get auth => _auth;
|
||||
final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance;
|
||||
// Repositories
|
||||
ReportsConnectorRepository? _reportsRepository;
|
||||
ShiftsConnectorRepository? _shiftsRepository;
|
||||
HubsConnectorRepository? _hubsRepository;
|
||||
BillingConnectorRepository? _billingRepository;
|
||||
HomeConnectorRepository? _homeRepository;
|
||||
CoverageConnectorRepository? _coverageRepository;
|
||||
StaffConnectorRepository? _staffRepository;
|
||||
|
||||
/// Cache for the current staff ID to avoid redundant lookups.
|
||||
String? _cachedStaffId;
|
||||
/// Gets the reports connector repository.
|
||||
ReportsConnectorRepository getReportsRepository() {
|
||||
return _reportsRepository ??= ReportsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Cache for the current business ID to avoid redundant lookups.
|
||||
String? _cachedBusinessId;
|
||||
/// Gets the shifts connector repository.
|
||||
ShiftsConnectorRepository getShiftsRepository() {
|
||||
return _shiftsRepository ??= ShiftsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the current staff ID from session store or persistent storage.
|
||||
/// Gets the hubs connector repository.
|
||||
HubsConnectorRepository getHubsRepository() {
|
||||
return _hubsRepository ??= HubsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the billing connector repository.
|
||||
BillingConnectorRepository getBillingRepository() {
|
||||
return _billingRepository ??= BillingConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the home connector repository.
|
||||
HomeConnectorRepository getHomeRepository() {
|
||||
return _homeRepository ??= HomeConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the coverage connector repository.
|
||||
CoverageConnectorRepository getCoverageRepository() {
|
||||
return _coverageRepository ??= CoverageConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the staff connector repository.
|
||||
StaffConnectorRepository getStaffRepository() {
|
||||
return _staffRepository ??= StaffConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Returns the current Firebase Auth instance.
|
||||
@override
|
||||
firebase.FirebaseAuth get auth => firebase.FirebaseAuth.instance;
|
||||
|
||||
/// Helper to get the current staff ID from the session.
|
||||
Future<String> getStaffId() async {
|
||||
// 1. Check Session Store
|
||||
final dc.StaffSession? session = dc.StaffSessionStore.instance.session;
|
||||
if (session?.staff?.id != null) {
|
||||
return session!.staff!.id;
|
||||
}
|
||||
|
||||
// 2. Check Cache
|
||||
if (_cachedStaffId != null) return _cachedStaffId!;
|
||||
|
||||
// 3. Fetch from Data Connect using Firebase UID
|
||||
final firebase_auth.User? user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User is not authenticated',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final fdc.QueryResult<
|
||||
dc.GetStaffByUserIdData,
|
||||
dc.GetStaffByUserIdVariables
|
||||
>
|
||||
response = await executeProtected(
|
||||
() => connector.getStaffByUserId(userId: user.uid).execute(),
|
||||
);
|
||||
|
||||
if (response.data.staffs.isNotEmpty) {
|
||||
_cachedStaffId = response.data.staffs.first.id;
|
||||
return _cachedStaffId!;
|
||||
String? staffId = dc.StaffSessionStore.instance.session?.ownerId;
|
||||
|
||||
if (staffId == null || staffId.isEmpty) {
|
||||
// Attempt to recover session if user is signed in
|
||||
final user = auth.currentUser;
|
||||
if (user != null) {
|
||||
await _loadSession(user.uid);
|
||||
staffId = dc.StaffSessionStore.instance.session?.ownerId;
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch staff ID from Data Connect: $e');
|
||||
}
|
||||
|
||||
// 4. Fallback (should ideally not happen if DB is seeded)
|
||||
return user.uid;
|
||||
if (staffId == null || staffId.isEmpty) {
|
||||
throw Exception('No staff ID found in session.');
|
||||
}
|
||||
return staffId;
|
||||
}
|
||||
|
||||
/// Gets the current business ID from session store or persistent storage.
|
||||
/// Helper to get the current business ID from the session.
|
||||
Future<String> getBusinessId() async {
|
||||
// 1. Check Session Store
|
||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||
if (session?.business?.id != null) {
|
||||
return session!.business!.id;
|
||||
}
|
||||
String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
||||
|
||||
// 2. Check Cache
|
||||
if (_cachedBusinessId != null) return _cachedBusinessId!;
|
||||
|
||||
// 3. Fetch from Data Connect using Firebase UID
|
||||
final firebase_auth.User? user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User is not authenticated',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final fdc.QueryResult<
|
||||
dc.GetBusinessesByUserIdData,
|
||||
dc.GetBusinessesByUserIdVariables
|
||||
>
|
||||
response = await executeProtected(
|
||||
() => connector.getBusinessesByUserId(userId: user.uid).execute(),
|
||||
);
|
||||
|
||||
if (response.data.businesses.isNotEmpty) {
|
||||
_cachedBusinessId = response.data.businesses.first.id;
|
||||
return _cachedBusinessId!;
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
// Attempt to recover session if user is signed in
|
||||
final user = auth.currentUser;
|
||||
if (user != null) {
|
||||
await _loadSession(user.uid);
|
||||
businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch business ID from Data Connect: $e');
|
||||
}
|
||||
|
||||
// 4. Fallback (should ideally not happen if DB is seeded)
|
||||
return user.uid;
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
throw Exception('No business ID found in session.');
|
||||
}
|
||||
return businessId;
|
||||
}
|
||||
|
||||
/// Converts a Data Connect timestamp/string/json to a [DateTime].
|
||||
DateTime? toDateTime(dynamic t) {
|
||||
if (t == null) return null;
|
||||
DateTime? dt;
|
||||
if (t is fdc.Timestamp) {
|
||||
dt = t.toDateTime();
|
||||
} else if (t is String) {
|
||||
dt = DateTime.tryParse(t);
|
||||
} else {
|
||||
try {
|
||||
dt = DateTime.tryParse(t.toJson() as String);
|
||||
} catch (_) {
|
||||
try {
|
||||
dt = DateTime.tryParse(t.toString());
|
||||
} catch (e) {
|
||||
dt = null;
|
||||
/// Logic to load session data from backend and populate stores.
|
||||
Future<void> _loadSession(String userId) async {
|
||||
try {
|
||||
final role = await fetchUserRole(userId);
|
||||
if (role == null) return;
|
||||
|
||||
// Load Staff Session if applicable
|
||||
if (role == 'STAFF' || role == 'BOTH') {
|
||||
final response = await connector.getStaffByUserId(userId: userId).execute();
|
||||
if (response.data.staffs.isNotEmpty) {
|
||||
final s = response.data.staffs.first;
|
||||
dc.StaffSessionStore.instance.setSession(
|
||||
dc.StaffSession(
|
||||
ownerId: s.id,
|
||||
staff: domain.Staff(
|
||||
id: s.id,
|
||||
authProviderId: s.userId,
|
||||
name: s.fullName,
|
||||
email: s.email ?? '',
|
||||
phone: s.phone,
|
||||
status: domain.StaffStatus.completedProfile,
|
||||
address: s.addres,
|
||||
avatar: s.photoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dt != null) {
|
||||
return DateTimeUtils.toDeviceTime(dt);
|
||||
// Load Client Session if applicable
|
||||
if (role == 'BUSINESS' || role == 'BOTH') {
|
||||
final response = await connector.getBusinessesByUserId(userId: userId).execute();
|
||||
if (response.data.businesses.isNotEmpty) {
|
||||
final b = response.data.businesses.first;
|
||||
dc.ClientSessionStore.instance.setSession(
|
||||
dc.ClientSession(
|
||||
business: dc.ClientBusinessSession(
|
||||
id: b.id,
|
||||
businessName: b.businessName,
|
||||
email: b.email,
|
||||
city: b.city,
|
||||
contactName: b.contactName,
|
||||
companyLogoUrl: b.companyLogoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DataConnectService: Error loading session for $userId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a Data Connect [Timestamp] to a Dart [DateTime].
|
||||
DateTime? toDateTime(dynamic timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
if (timestamp is fdc.Timestamp) {
|
||||
return timestamp.toDateTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Converts a [DateTime] to a Firebase Data Connect [Timestamp].
|
||||
/// Converts a Dart [DateTime] to a Data Connect [Timestamp].
|
||||
fdc.Timestamp toTimestamp(DateTime dateTime) {
|
||||
final DateTime utc = dateTime.toUtc();
|
||||
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
|
||||
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
|
||||
return fdc.Timestamp(nanoseconds, seconds);
|
||||
final int millis = utc.millisecondsSinceEpoch;
|
||||
final int seconds = millis ~/ 1000;
|
||||
final int nanos = (millis % 1000) * 1000000;
|
||||
return fdc.Timestamp(nanos, seconds);
|
||||
}
|
||||
|
||||
// --- 3. Unified Execution ---
|
||||
// Repositories call this to benefit from centralized error handling/logging
|
||||
/// Converts a nullable Dart [DateTime] to a nullable Data Connect [Timestamp].
|
||||
fdc.Timestamp? tryToTimestamp(DateTime? dateTime) {
|
||||
if (dateTime == null) return null;
|
||||
return toTimestamp(dateTime);
|
||||
}
|
||||
|
||||
/// Executes an operation with centralized error handling.
|
||||
@override
|
||||
Future<T> run<T>(
|
||||
Future<T> Function() action, {
|
||||
Future<T> Function() operation, {
|
||||
bool requiresAuthentication = true,
|
||||
}) async {
|
||||
if (requiresAuthentication && auth.currentUser == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User must be authenticated to perform this action',
|
||||
);
|
||||
}
|
||||
|
||||
return executeProtected(() async {
|
||||
// Ensure session token is valid and refresh if needed
|
||||
if (requiresAuthentication) {
|
||||
await ensureSessionValid();
|
||||
return action();
|
||||
});
|
||||
}
|
||||
|
||||
/// Clears the internal cache (e.g., on logout).
|
||||
void clearCache() {
|
||||
_cachedStaffId = null;
|
||||
_cachedBusinessId = null;
|
||||
}
|
||||
|
||||
/// Handle session sign-out by clearing caches.
|
||||
void handleSignOut() {
|
||||
clearCache();
|
||||
}
|
||||
return executeProtected(operation);
|
||||
}
|
||||
|
||||
/// Implementation for SessionHandlerMixin.
|
||||
@override
|
||||
Future<String?> fetchUserRole(String userId) async {
|
||||
try {
|
||||
final fdc.QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables>
|
||||
response = await executeProtected(
|
||||
() => connector.getUserById(id: userId).execute(),
|
||||
);
|
||||
final response = await connector.getUserById(id: userId).execute();
|
||||
return response.data.user?.userRole;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch user role: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose all resources (call on app shutdown).
|
||||
Future<void> dispose() async {
|
||||
await disposeSessionHandler();
|
||||
/// Clears Cached Repositories and Session data.
|
||||
void clearCache() {
|
||||
_reportsRepository = null;
|
||||
_shiftsRepository = null;
|
||||
_hubsRepository = null;
|
||||
_billingRepository = null;
|
||||
_homeRepository = null;
|
||||
_coverageRepository = null;
|
||||
_staffRepository = null;
|
||||
|
||||
dc.StaffSessionStore.instance.clear();
|
||||
dc.ClientSessionStore.instance.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ mixin SessionHandlerMixin {
|
||||
_authStateSubscription = auth.authStateChanges().listen(
|
||||
(firebase_auth.User? user) async {
|
||||
if (user == null) {
|
||||
_handleSignOut();
|
||||
handleSignOut();
|
||||
} else {
|
||||
await _handleSignIn(user);
|
||||
}
|
||||
@@ -235,7 +235,7 @@ mixin SessionHandlerMixin {
|
||||
}
|
||||
|
||||
/// Handle user sign-out event.
|
||||
void _handleSignOut() {
|
||||
void handleSignOut() {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user