Initial commit of Doormile CRM Mobile with UI modernizations

This commit is contained in:
2026-06-26 13:43:37 +05:30
commit 3965046984
183 changed files with 18135 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/client.dart';
class QdrantService {
static const String _baseUrl = 'http://66.116.207.225:6333';
static const String _apiKey = 'Package@321#';
static const String collectionName = 'doormile_clients';
// 16-dim feature vector (city-agnostic — city stored in payload only)
//
// [0] parcelVolume (normalised 01 over max 1 200)
// [1] activeContracts (normalised 01 over max 40)
// [25] businessType one-hot (ecommerce, manufacturing, wholesale, retail)
// [69] status one-hot (newClient, pending, completed, updated)
// [10] surveySubmitted (0 / 1)
// [11] dataConsent basicOnly=0, full=1
// [12] hasPhone (0 / 1)
// [13] hasTransitRoute both from+to non-empty (0 / 1)
// [14] frequencyScore none=0, monthly=0.25, biweekly=0.5, weekly=0.75, daily=1
// [15] hasNeighbourhood (0 / 1)
static const int _vectorSize = 16;
final http.Client _http;
QdrantService({http.Client? client}) : _http = client ?? http.Client();
Map<String, String> get _headers => {
'Content-Type': 'application/json',
'api-key': _apiKey,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
};
/// Creates the collection if it does not already exist.
Future<void> ensureCollection() async {
final res = await _http.get(
Uri.parse('$_baseUrl/collections/$collectionName'),
headers: _headers,
);
if (res.statusCode == 200) return;
await _http.put(
Uri.parse('$_baseUrl/collections/$collectionName'),
headers: _headers,
body: jsonEncode({
'vectors': {'size': _vectorSize, 'distance': 'Cosine'},
}),
);
}
/// Upsert (insert or update) a single client.
Future<void> upsertClient(Client client) async {
await _http.put(
Uri.parse('$_baseUrl/collections/$collectionName/points'),
headers: _headers,
body: jsonEncode({
'points': [_toPoint(client)],
}),
);
}
/// Batch upsert a list of clients.
Future<void> upsertClients(List<Client> clients) async {
if (clients.isEmpty) return;
await _http.put(
Uri.parse('$_baseUrl/collections/$collectionName/points'),
headers: _headers,
body: jsonEncode({
'points': clients.map(_toPoint).toList(),
}),
);
}
/// Fetch all stored clients from Qdrant (up to 500).
Future<List<Client>> loadClients() async {
final res = await _http.post(
Uri.parse('$_baseUrl/collections/$collectionName/points/scroll'),
headers: _headers,
body: jsonEncode({
'limit': 500,
'with_payload': true,
'with_vector': false,
}),
);
if (res.statusCode != 200) return [];
final body = jsonDecode(res.body) as Map<String, dynamic>;
final points = (body['result']?['points'] as List?) ?? [];
return points.map((p) => _fromPoint(p as Map<String, dynamic>)).toList();
}
/// Drop and recreate the collection — used for staging resets.
Future<void> deleteCollection() async {
await _http.delete(
Uri.parse('$_baseUrl/collections/$collectionName'),
headers: _headers,
);
}
/// Delete a client by its string ID.
Future<void> deleteClient(String clientId) async {
await _http.post(
Uri.parse('$_baseUrl/collections/$collectionName/points/delete'),
headers: _headers,
body: jsonEncode({
'points': [_stableId(clientId)],
}),
);
}
// ── Helpers ───────────────────────────────────────────────────────────────
int _stableId(String id) {
int h = 0;
for (final c in id.codeUnits) {
h = (h * 31 + c) & 0x7FFFFFFF;
}
return h == 0 ? 1 : h;
}
Map<String, dynamic> _toPoint(Client client) => {
'id': _stableId(client.id),
'vector': _toVector(client),
'payload': {
'clientId': client.id,
'name': client.name,
'city': client.city,
'businessType': client.businessType.name,
'parcelVolume': client.parcelVolume,
'activeContracts': client.activeContracts,
'provider': client.provider,
'efficiency': client.efficiency,
'status': client.status.name,
'lastUpdated': client.lastUpdated,
'surveySubmitted': client.surveySubmitted,
'notes': client.notes,
'dataConsent': client.dataConsent.name,
'phone': client.phone,
'frequency': client.frequency,
'businessState': client.businessState,
'transitType': client.transitType,
'transitFrom': client.transitFrom,
'transitTo': client.transitTo,
'neighbourhood': client.neighbourhood,
'logisticsSegment': client.logisticsSegment,
'pincode': client.pincode,
// GPS captured at submission — not shown in app, stored for field audit
'surveyLat': client.surveyLat,
'surveyLng': client.surveyLng,
'surveyAddress': client.surveyAddress,
'surveyZone': client.surveyZone,
'surveyPincode': client.surveyPincode,
// Field-agent attribution — who collected this survey (login session)
'recordedByName': client.recordedByName,
'recordedByEmail': client.recordedByEmail,
'recordedByRole': client.recordedByRole,
// Qdrant geo payload — enables geo-radius queries later
if (client.surveyLat != 0.0 || client.surveyLng != 0.0)
'surveyGeo': {'lat': client.surveyLat, 'lon': client.surveyLng},
},
};
List<double> _toVector(Client client) {
final v = List<double>.filled(_vectorSize, 0.0);
v[0] = (client.parcelVolume / 1200.0).clamp(0.0, 1.0);
v[1] = (client.activeContracts / 40.0).clamp(0.0, 1.0);
v[2 + client.businessType.index] = 1.0;
v[6 + client.status.index] = 1.0;
v[10] = client.surveySubmitted ? 1.0 : 0.0;
v[11] = client.dataConsent == DataConsent.full ? 1.0 : 0.0;
v[12] = client.phone.isNotEmpty ? 1.0 : 0.0;
v[13] = (client.transitFrom.isNotEmpty && client.transitTo.isNotEmpty)
? 1.0
: 0.0;
v[14] = _frequencyScore(client.frequency);
v[15] = client.neighbourhood.isNotEmpty ? 1.0 : 0.0;
return v;
}
double _frequencyScore(String freq) {
switch (freq.toLowerCase()) {
case 'daily':
return 1.0;
case 'weekly':
return 0.75;
case 'bi-weekly':
return 0.5;
case 'monthly':
return 0.25;
default:
return 0.0;
}
}
Client _fromPoint(Map<String, dynamic> point) {
final p = point['payload'] as Map<String, dynamic>;
return Client(
id: p['clientId'] as String,
name: p['name'] as String,
city: p['city'] as String,
businessType:
BusinessType.values.firstWhere((b) => b.name == p['businessType']),
parcelVolume: (p['parcelVolume'] as num?)?.toDouble() ?? 0.0,
activeContracts: p['activeContracts'] as int,
provider: p['provider'] as String,
efficiency: p['efficiency'] as String,
status: ClientStatus.values.firstWhere((s) => s.name == p['status']),
lastUpdated: p['lastUpdated'] as String,
surveySubmitted: p['surveySubmitted'] as bool,
notes: (p['notes'] as String?) ?? '',
dataConsent: DataConsent.values.firstWhere(
(d) => d.name == (p['dataConsent'] as String? ?? 'full'),
orElse: () => DataConsent.full,
),
phone: (p['phone'] as String?) ?? '',
frequency: (p['frequency'] as String?) ?? '',
businessState: (p['businessState'] as String?) ?? '',
transitType: (p['transitType'] as String?) ?? '',
transitFrom: (p['transitFrom'] as String?) ?? '',
transitTo: (p['transitTo'] as String?) ?? '',
neighbourhood: (p['neighbourhood'] as String?) ?? '',
logisticsSegment: (p['logisticsSegment'] as String?) ?? '',
pincode: (p['pincode'] as String?) ?? '',
surveyLat: (p['surveyLat'] as num?)?.toDouble() ?? 0.0,
surveyLng: (p['surveyLng'] as num?)?.toDouble() ?? 0.0,
surveyAddress: (p['surveyAddress'] as String?) ?? '',
surveyZone: (p['surveyZone'] as String?) ?? '',
surveyPincode: (p['surveyPincode'] as String?) ?? '',
recordedByName: (p['recordedByName'] as String?) ?? '',
recordedByEmail: (p['recordedByEmail'] as String?) ?? '',
recordedByRole: (p['recordedByRole'] as String?) ?? '',
);
}
}