Initial commit — DLS lab-app Flutter project
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import '../../features/shared/job_files_repository.dart';
|
||||
import '../../features/shared/tenant_team_repository.dart';
|
||||
import '../../models/job_file.dart';
|
||||
import '../../models/tenant.dart';
|
||||
import '../api/pocketbase_client.dart';
|
||||
|
||||
// ── Message segments ──────────────────────────────────────────────────────────
|
||||
|
||||
sealed class MessageSegment {}
|
||||
|
||||
class TextSegment extends MessageSegment {
|
||||
TextSegment(this.text);
|
||||
final String text;
|
||||
}
|
||||
|
||||
class ActionSegment extends MessageSegment {
|
||||
ActionSegment(this.action);
|
||||
final AiAction action;
|
||||
}
|
||||
|
||||
// ── Action model ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AiAction {
|
||||
const AiAction({
|
||||
required this.type,
|
||||
required this.params,
|
||||
required this.label,
|
||||
});
|
||||
final String type;
|
||||
final Map<String, String> params;
|
||||
final String label;
|
||||
|
||||
bool get isDangerous => type == 'cancel_job';
|
||||
bool get isFileAction => type == 'job_files';
|
||||
}
|
||||
|
||||
// ── Action outcome ────────────────────────────────────────────────────────────
|
||||
|
||||
sealed class ActionOutcome {}
|
||||
|
||||
class ActionSuccess extends ActionOutcome {
|
||||
ActionSuccess(this.message);
|
||||
final String message;
|
||||
}
|
||||
|
||||
class ActionError extends ActionOutcome {
|
||||
ActionError(this.error);
|
||||
final String error;
|
||||
}
|
||||
|
||||
class ActionFiles extends ActionOutcome {
|
||||
ActionFiles(this.files);
|
||||
final List<JobFile> files;
|
||||
}
|
||||
|
||||
// ── Parser ────────────────────────────────────────────────────────────────────
|
||||
|
||||
List<MessageSegment> parseSegments(String text) {
|
||||
// Strip code fences wrapping <dls-action> tags that the AI sometimes emits.
|
||||
// Handles: ```xml\n<dls-action .../>\n``` and ```\n<dls-action .../>\n```
|
||||
text = text.replaceAllMapped(
|
||||
RegExp(r'```(?:xml)?\s*\n(\s*<dls-action\s[^>]*/>)\s*\n\s*```'),
|
||||
(m) => m.group(1)!,
|
||||
);
|
||||
// Also handle inline variant: ```xml <dls-action .../> ```
|
||||
text = text.replaceAllMapped(
|
||||
RegExp(r'```(?:xml)?\s*(<dls-action\s[^>]*/>)\s*```'),
|
||||
(m) => m.group(1)!,
|
||||
);
|
||||
|
||||
final pattern = RegExp(r'<dls-action\s([^/]*?)/>', dotAll: true);
|
||||
final segments = <MessageSegment>[];
|
||||
int last = 0;
|
||||
|
||||
for (final m in pattern.allMatches(text)) {
|
||||
final before = text.substring(last, m.start).trim();
|
||||
if (before.isNotEmpty) segments.add(TextSegment(before));
|
||||
|
||||
final attrs = _parseAttrs(m.group(1) ?? '');
|
||||
segments.add(ActionSegment(AiAction(
|
||||
type: attrs['type'] ?? '',
|
||||
params: attrs,
|
||||
label: attrs['label'] ?? attrs['type'] ?? 'İşlem',
|
||||
)));
|
||||
last = m.end;
|
||||
}
|
||||
|
||||
final rest = text.substring(last).trim();
|
||||
if (rest.isNotEmpty) segments.add(TextSegment(rest));
|
||||
return segments;
|
||||
}
|
||||
|
||||
Map<String, String> _parseAttrs(String s) {
|
||||
final result = <String, String>{};
|
||||
for (final m in RegExp(r'(\w+)="([^"]*)"').allMatches(s)) {
|
||||
result[m.group(1)!] = m.group(2)!;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Executor ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class AiActionExecutor {
|
||||
static final _pb = PocketBaseClient.instance.pb;
|
||||
|
||||
static Future<ActionOutcome> execute(
|
||||
AiAction action,
|
||||
TenantMembership membership,
|
||||
) async {
|
||||
try {
|
||||
return switch (action.type) {
|
||||
'cancel_job' => await _cancelJob(action.params),
|
||||
'mark_delivered' => await _markDelivered(action.params),
|
||||
'job_files' => await _jobFiles(action.params),
|
||||
'add_member' => await _addMember(action.params, membership),
|
||||
_ => ActionError('Bilinmeyen işlem türü: ${action.type}'),
|
||||
};
|
||||
} catch (e) {
|
||||
final msg = e.toString();
|
||||
if (msg.length > 120) return ActionError('Sunucu hatası');
|
||||
return ActionError(msg);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<ActionOutcome> _cancelJob(Map<String, String> p) async {
|
||||
final id = p['job_id'];
|
||||
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
|
||||
await _pb.collection('jobs').update(id, body: {'status': 'cancelled'});
|
||||
return ActionSuccess('İş başarıyla iptal edildi.');
|
||||
}
|
||||
|
||||
static Future<ActionOutcome> _markDelivered(Map<String, String> p) async {
|
||||
final id = p['job_id'];
|
||||
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
|
||||
await _pb.collection('jobs').update(id, body: {'status': 'delivered'});
|
||||
return ActionSuccess('İş teslim edildi olarak işaretlendi.');
|
||||
}
|
||||
|
||||
static Future<ActionOutcome> _jobFiles(Map<String, String> p) async {
|
||||
final id = p['job_id'];
|
||||
if (id == null || id.isEmpty) return ActionError('İş ID bulunamadı.');
|
||||
final files = await JobFilesRepository.instance.listForJob(id);
|
||||
if (files.isEmpty) return ActionSuccess('Bu iş için henüz dosya yüklenmemiş.');
|
||||
return ActionFiles(files);
|
||||
}
|
||||
|
||||
static Future<ActionOutcome> _addMember(
|
||||
Map<String, String> p,
|
||||
TenantMembership membership,
|
||||
) async {
|
||||
final email = p['email'];
|
||||
final firstName = p['first_name'];
|
||||
final lastName = p['last_name'] ?? '';
|
||||
final role = p['role'];
|
||||
final password = p['password'];
|
||||
|
||||
if (email == null || firstName == null || role == null || password == null) {
|
||||
return ActionError('Eksik bilgi: e-posta, ad, rol ve şifre gerekli.');
|
||||
}
|
||||
|
||||
await TenantTeamRepository.instance.addMember(
|
||||
tenantId: membership.tenant.id,
|
||||
email: email,
|
||||
password: password,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
role: TenantMembership.parseRole(role),
|
||||
);
|
||||
return ActionSuccess('$firstName $lastName ekibe eklendi.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../api/pocketbase_client.dart';
|
||||
import '../../models/tenant.dart';
|
||||
|
||||
class AiContextBuilder {
|
||||
AiContextBuilder._();
|
||||
static final instance = AiContextBuilder._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<String> build(TenantMembership membership) async {
|
||||
final tenant = membership.tenant;
|
||||
final tenantId = tenant.id;
|
||||
final isLab = tenant.kind == TenantKind.lab;
|
||||
|
||||
final now = DateTime.now();
|
||||
final dateStr = '${now.day}.${now.month}.${now.year}';
|
||||
|
||||
final results = await Future.wait([
|
||||
_fetchActiveJobs(tenantId, isLab),
|
||||
_fetchRecentDelivered(tenantId, isLab),
|
||||
_fetchFinance(tenantId, isLab),
|
||||
_fetchTeam(tenantId),
|
||||
]);
|
||||
|
||||
final actions = _actionsPrompt(isLab);
|
||||
|
||||
return 'Sen DLS (Dental Lab System) uygulamasinin akilli asistanisin.\n'
|
||||
'${tenant.companyName} adli ${isLab ? 'dental laboratuvarinin' : 'dis kliniginin'} verilerine erisebilirsin.\n'
|
||||
'Kullanici rolu: ${isLab ? 'LABORATUVAR' : 'KLINIK'}\n'
|
||||
'\n'
|
||||
'Tarih: $dateStr\n'
|
||||
'\n'
|
||||
'${results[0]}\n'
|
||||
'\n'
|
||||
'${results[1]}\n'
|
||||
'\n'
|
||||
'${results[2]}\n'
|
||||
'\n'
|
||||
'${results[3]}\n'
|
||||
'\n'
|
||||
'$actions\n'
|
||||
'\n'
|
||||
'Yanit kurallari:\n'
|
||||
'- Turkce, kisa ve net yaz\n'
|
||||
'- Sadece yukaridaki verilerden hareketle yorum yap\n'
|
||||
'- Listelerde madde isareti (- ) kullan\n'
|
||||
'- Onemli bilgileri **kalin** yaz\n'
|
||||
'- Aksiyon etiketlerini HERZAMAN metnin sonuna koy\n'
|
||||
'- ${isLab ? 'Is kodlari icin [ID:...] formatini kullan' : 'Hasta kodlari ve is durumlarini net belirt'}\n';
|
||||
}
|
||||
|
||||
static String _actionsPrompt(bool isLab) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('## EYLEM YETKILERIN');
|
||||
buf.writeln('Kullanici bir islem yapmak istediginde asagidaki XML etiketlerini yanita ekle:');
|
||||
buf.writeln('');
|
||||
buf.writeln('Is dosyalarini gostermek:');
|
||||
buf.writeln('<dls-action type="job_files" job_id="JOB_ID" label="AB001 dosyalarini goster"/>');
|
||||
buf.writeln('');
|
||||
buf.writeln('Is iptal etmek:');
|
||||
buf.writeln('<dls-action type="cancel_job" job_id="JOB_ID" label="AB001 isini iptal et"/>');
|
||||
if (!isLab) {
|
||||
buf.writeln('');
|
||||
buf.writeln('Teslim edildi isaretlemek (sadece klinik):');
|
||||
buf.writeln('<dls-action type="mark_delivered" job_id="JOB_ID" label="AB001 teslim edildi"/>');
|
||||
}
|
||||
buf.writeln('');
|
||||
buf.writeln('Ekip uyesi eklemek (TUM bilgiler alindiktan sonra):');
|
||||
buf.writeln('<dls-action type="add_member" email="..." first_name="..." last_name="..." role="technician|admin|doctor|delivery|finance|member" password="..." label="Ad Soyad ekle"/>');
|
||||
buf.writeln('');
|
||||
buf.writeln('KURALLAR:');
|
||||
buf.writeln('- Etiketi SADECE kullanici acikca islem istediginde ekle');
|
||||
buf.writeln('- Sifre sorulursa kullanicidan al, ASLA uydurma');
|
||||
buf.writeln('- iptal gibi geri alinmaz islemleri acikca belirt');
|
||||
buf.writeln('- Etiket icindeki job_id degerini yukaridaki is listesinden al');
|
||||
buf.writeln('- <dls-action> etiketlerini KESINLİKLE kod blogu (```xml veya ```) icine ALMA, duz metin olarak yaz');
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
Future<String> _fetchActiveJobs(String tenantId, bool isLab) async {
|
||||
try {
|
||||
final tenantField = isLab ? 'lab_tenant_id' : 'clinic_tenant_id';
|
||||
final counterpartField = isLab ? 'clinic_tenant_id' : 'lab_tenant_id';
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
filter: '$tenantField = "$tenantId" && status != "delivered" && status != "cancelled"',
|
||||
perPage: 60,
|
||||
sort: '-created',
|
||||
expand: counterpartField,
|
||||
);
|
||||
|
||||
if (result.items.isEmpty) return '## Aktif Isler\nSu an aktif is yok.';
|
||||
|
||||
final counterpartLabel = isLab ? 'Klinik' : 'Lab';
|
||||
final lines = result.items.map((r) {
|
||||
final j = r.toJson();
|
||||
final jobId = j['id'] as String? ?? '';
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final counterpart =
|
||||
(expand?[counterpartField] as Map?)?['company_name'] as String? ?? '-';
|
||||
final status = _statusTr(j['status'] as String? ?? '');
|
||||
final prosthetic = j['prosthetic_type'] as String? ?? '-';
|
||||
final patient = j['patient_code'] as String? ?? '-';
|
||||
final step = j['current_step'] as String?;
|
||||
final stepPart = (step != null && step.isNotEmpty) ? ' | Adim: $step' : '';
|
||||
final due = j['due_date'] as String? ?? '';
|
||||
final duePart = due.isNotEmpty ? ' | Termin: ${due.substring(0, 10)}' : '';
|
||||
return '- [ID:$jobId] Hasta: $patient | $prosthetic | $status$stepPart | $counterpartLabel: $counterpart$duePart';
|
||||
}).join('\n');
|
||||
|
||||
return '## Aktif Isler (${result.items.length})\n$lines';
|
||||
} catch (e) {
|
||||
return '## Aktif Isler\n(Veri alinamadi: $e)';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _fetchRecentDelivered(String tenantId, bool isLab) async {
|
||||
try {
|
||||
final tenantField = isLab ? 'lab_tenant_id' : 'clinic_tenant_id';
|
||||
final counterpartField = isLab ? 'clinic_tenant_id' : 'lab_tenant_id';
|
||||
final result = await _pb.collection('jobs').getList(
|
||||
filter: '$tenantField = "$tenantId" && status = "delivered"',
|
||||
perPage: 10,
|
||||
sort: '-updated',
|
||||
expand: counterpartField,
|
||||
);
|
||||
|
||||
if (result.items.isEmpty) return '## Son Teslim Edilenler\nHenuz teslim edilen is yok.';
|
||||
|
||||
final counterpartLabel = isLab ? 'Klinik' : 'Lab';
|
||||
final lines = result.items.map((r) {
|
||||
final j = r.toJson();
|
||||
final jobId = j['id'] as String? ?? '';
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final counterpart =
|
||||
(expand?[counterpartField] as Map?)?['company_name'] as String? ?? '-';
|
||||
final prosthetic = j['prosthetic_type'] as String? ?? '-';
|
||||
final patient = j['patient_code'] as String? ?? '-';
|
||||
final updated = (j['updated'] as String? ?? '');
|
||||
final datePart = updated.length >= 10 ? updated.substring(0, 10) : '';
|
||||
return '- [ID:$jobId] Hasta: $patient | $prosthetic | $counterpartLabel: $counterpart${datePart.isNotEmpty ? ' | Tarih: $datePart' : ''}';
|
||||
}).join('\n');
|
||||
|
||||
return '## Son Teslim Edilenler (son 10)\n$lines';
|
||||
} catch (_) {
|
||||
return '## Son Teslim Edilenler\n(Veri alinamadi)';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _fetchFinance(String tenantId, bool isLab) async {
|
||||
try {
|
||||
final type = isLab ? 'receivable' : 'payable';
|
||||
final result = await _pb.collection('finance_entries').getList(
|
||||
filter: 'tenant_id = "$tenantId" && type = "$type"',
|
||||
perPage: 200,
|
||||
);
|
||||
|
||||
double pending = 0, paid = 0;
|
||||
for (final r in result.items) {
|
||||
final j = r.toJson();
|
||||
final amount = (j['amount'] as num?)?.toDouble() ?? 0;
|
||||
if (j['status'] == 'pending') {
|
||||
pending += amount;
|
||||
} else {
|
||||
paid += amount;
|
||||
}
|
||||
}
|
||||
|
||||
final label = isLab ? 'alacak' : 'borc';
|
||||
return '## Finans\n'
|
||||
'- Bekleyen $label: ${pending.toStringAsFixed(0)} TL\n'
|
||||
'- Tahsil edilen: ${paid.toStringAsFixed(0)} TL';
|
||||
} catch (_) {
|
||||
return '## Finans\n(Veri alinamadi)';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _fetchTeam(String tenantId) async {
|
||||
try {
|
||||
final result = await _pb.collection('tenant_members').getList(
|
||||
filter: 'tenant_id = "$tenantId"',
|
||||
expand: 'user_id',
|
||||
perPage: 50,
|
||||
);
|
||||
|
||||
if (result.items.isEmpty) return '## Ekip\nUye yok.';
|
||||
|
||||
final lines = result.items.map((r) {
|
||||
final j = r.toJson();
|
||||
final expand = j['expand'] as Map<String, dynamic>?;
|
||||
final user = expand?['user_id'] as Map<String, dynamic>?;
|
||||
final first = (user?['first_name'] as String?) ?? '';
|
||||
final last = (user?['last_name'] as String?) ?? '';
|
||||
final email = (user?['email'] as String?) ?? '';
|
||||
final name =
|
||||
'$first $last'.trim().isNotEmpty ? '$first $last'.trim() : email;
|
||||
final role = _roleTr(j['role'] as String? ?? '');
|
||||
return '- $name ($role)';
|
||||
}).join('\n');
|
||||
|
||||
return '## Ekip (${result.items.length} uye)\n$lines';
|
||||
} catch (_) {
|
||||
return '## Ekip\n(Veri alinamadi)';
|
||||
}
|
||||
}
|
||||
|
||||
static String _statusTr(String s) => switch (s) {
|
||||
'pending' => 'Bekliyor',
|
||||
'in_progress' => 'Devam ediyor',
|
||||
'sent' => 'Gonderildi',
|
||||
'revision' => 'Revizyon',
|
||||
'delivered' => 'Teslim edildi',
|
||||
'cancelled' => 'Iptal',
|
||||
_ => s,
|
||||
};
|
||||
|
||||
static String _roleTr(String s) => switch (s) {
|
||||
'owner' => 'Sahibi',
|
||||
'admin' => 'Yonetici',
|
||||
'technician' => 'Teknisyen',
|
||||
'delivery' => 'Teslimat',
|
||||
'finance' => 'Finans',
|
||||
'doctor' => 'Hekim',
|
||||
_ => 'Uye',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class AiService {
|
||||
static const _baseUrl = 'https://api.featherless.ai/v1';
|
||||
static const _apiKey =
|
||||
'rc_e10f49aaa4f7af03dcd9da115cfc12cc1988665e895955c11f77788ee5ad93c6';
|
||||
static const _model = 'Qwen/Qwen2.5-7B-Instruct';
|
||||
|
||||
AiService._();
|
||||
static final instance = AiService._();
|
||||
|
||||
Stream<String> streamChat({
|
||||
required String systemPrompt,
|
||||
required List<Map<String, String>> messages,
|
||||
}) async* {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final request = http.Request(
|
||||
'POST',
|
||||
Uri.parse('$_baseUrl/chat/completions'),
|
||||
);
|
||||
request.headers.addAll({
|
||||
'Authorization': 'Bearer $_apiKey',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
request.body = jsonEncode({
|
||||
'model': _model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': systemPrompt},
|
||||
...messages,
|
||||
],
|
||||
'stream': true,
|
||||
'max_tokens': 2048,
|
||||
'temperature': 0.7,
|
||||
});
|
||||
|
||||
final response = await client.send(request);
|
||||
if (response.statusCode != 200) {
|
||||
final body = await response.stream.bytesToString();
|
||||
String msg = 'API hatası ${response.statusCode}';
|
||||
try {
|
||||
final j = jsonDecode(body) as Map<String, dynamic>;
|
||||
msg = (j['error'] as Map?)?['message'] as String? ?? msg;
|
||||
} catch (_) {}
|
||||
throw Exception(msg);
|
||||
}
|
||||
|
||||
final lines = response.stream
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
|
||||
await for (final line in lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
final payload = line.substring(6).trim();
|
||||
if (payload == '[DONE]') break;
|
||||
try {
|
||||
final j = jsonDecode(payload) as Map<String, dynamic>;
|
||||
final choices = j['choices'] as List?;
|
||||
if (choices == null || choices.isEmpty) continue;
|
||||
final delta = choices.first['delta'] as Map<String, dynamic>?;
|
||||
final content = delta?['content'] as String?;
|
||||
if (content != null && content.isNotEmpty) yield content;
|
||||
} catch (_) {}
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../api/pocketbase_client.dart';
|
||||
import '../../models/job.dart';
|
||||
|
||||
class JobHistoryEntry {
|
||||
const JobHistoryEntry({
|
||||
required this.id,
|
||||
required this.action,
|
||||
required this.createdAt,
|
||||
this.step,
|
||||
this.note,
|
||||
});
|
||||
final String id;
|
||||
final JobHistoryAction action;
|
||||
final JobStep? step;
|
||||
final String? note;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
|
||||
enum JobHistoryAction {
|
||||
accepted,
|
||||
handedToClinic,
|
||||
approved,
|
||||
revisionRequested,
|
||||
delivered,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
extension JobHistoryActionExt on JobHistoryAction {
|
||||
String get value => switch (this) {
|
||||
JobHistoryAction.accepted => 'accepted',
|
||||
JobHistoryAction.handedToClinic => 'handed_to_clinic',
|
||||
JobHistoryAction.approved => 'approved',
|
||||
JobHistoryAction.revisionRequested => 'revision_requested',
|
||||
JobHistoryAction.delivered => 'delivered',
|
||||
JobHistoryAction.cancelled => 'cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
class JobHistoryService {
|
||||
JobHistoryService._();
|
||||
static final instance = JobHistoryService._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
String get _currentUserId =>
|
||||
(_pb.authStore.record?.id) ?? (_pb.authStore.model as dynamic)?.id as String? ?? '';
|
||||
|
||||
Future<List<JobHistoryEntry>> listForJob(String jobId) async {
|
||||
try {
|
||||
final result = await _pb.collection('job_status_history').getList(
|
||||
filter: 'job_id = "$jobId"',
|
||||
perPage: 200,
|
||||
);
|
||||
return (result.items.map((r) {
|
||||
final j = r.toJson();
|
||||
String? str(dynamic v) {
|
||||
final s = v as String?;
|
||||
return (s == null || s.isEmpty) ? null : s;
|
||||
}
|
||||
return JobHistoryEntry(
|
||||
id: j['id'] as String,
|
||||
action: _parseAction(j['action_type'] as String? ?? ''),
|
||||
step: str(j['step']) != null ? _parseStep(j['step'] as String) : null,
|
||||
note: str(j['note']),
|
||||
createdAt: DateTime.parse(j['created'] as String),
|
||||
);
|
||||
}).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt)));
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static JobHistoryAction _parseAction(String s) => switch (s) {
|
||||
'accepted' => JobHistoryAction.accepted,
|
||||
'handed_to_clinic' => JobHistoryAction.handedToClinic,
|
||||
'approved' => JobHistoryAction.approved,
|
||||
'revision_requested' => JobHistoryAction.revisionRequested,
|
||||
'delivered' => JobHistoryAction.delivered,
|
||||
_ => JobHistoryAction.cancelled,
|
||||
};
|
||||
|
||||
static JobStep _parseStep(String s) => switch (s) {
|
||||
'alt_yapi_prova' => JobStep.altYapiProva,
|
||||
'ust_yapi_prova' => JobStep.ustYapiProva,
|
||||
'mum_prova' => JobStep.mumProva,
|
||||
'disler_prova' => JobStep.dislerProva,
|
||||
'dayanak_prova' => JobStep.dayanakProva,
|
||||
'kron_prova' => JobStep.kronProva,
|
||||
'cila_bitim' => JobStep.cilaBitim,
|
||||
_ => JobStep.olcu,
|
||||
};
|
||||
|
||||
Future<void> append({
|
||||
required String jobId,
|
||||
required String clinicTenantId,
|
||||
required String labTenantId,
|
||||
required JobHistoryAction action,
|
||||
JobStep? step,
|
||||
String? note,
|
||||
String? userId,
|
||||
}) async {
|
||||
try {
|
||||
await _pb.collection('job_status_history').create(body: {
|
||||
'job_id': jobId,
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'completed_by': userId ?? _currentUserId,
|
||||
'action_type': action.value,
|
||||
if (step != null) 'step': step.value,
|
||||
if (note != null && note.isNotEmpty) 'note': note,
|
||||
});
|
||||
} catch (_) {
|
||||
// history failures must never block the main mutation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:onesignal_flutter/onesignal_flutter.dart';
|
||||
|
||||
// ─── Replace with your OneSignal App ID from onesignal.com ──────────────────
|
||||
const _kOneSignalAppId = '524cb6d8-2640-4f85-bb24-c9c762233de7';
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class NotificationService {
|
||||
NotificationService._();
|
||||
|
||||
static GoRouter? _router;
|
||||
static bool _initialized = false;
|
||||
|
||||
static void setRouter(GoRouter router) => _router = router;
|
||||
|
||||
static bool get _supported =>
|
||||
!kIsWeb && (Platform.isIOS || Platform.isAndroid || Platform.isMacOS);
|
||||
|
||||
static Future<void> init() async {
|
||||
if (!_supported || _initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
OneSignal.initialize(_kOneSignalAppId);
|
||||
await OneSignal.Notifications.requestPermission(true);
|
||||
|
||||
// Show notification even when app is in foreground
|
||||
OneSignal.Notifications.addForegroundWillDisplayListener((event) {
|
||||
event.notification.display();
|
||||
});
|
||||
|
||||
// Tap → navigate to job detail
|
||||
OneSignal.Notifications.addClickListener((event) {
|
||||
final data = event.notification.additionalData;
|
||||
if (data == null) return;
|
||||
final jobId = data['job_id'] as String?;
|
||||
final tenantType = data['tenant_type'] as String?;
|
||||
if (jobId == null || _router == null) return;
|
||||
if (tenantType == 'lab') {
|
||||
_router!.push('/lab/jobs/$jobId');
|
||||
} else {
|
||||
_router!.push('/clinic/jobs/$jobId');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Call after successful login. Links the OneSignal player to this user.
|
||||
static Future<void> loginUser(String userId, {bool isLab = false}) async {
|
||||
if (!_supported) return;
|
||||
try {
|
||||
await OneSignal.login(userId);
|
||||
OneSignal.User.addTagWithKey('tenant_type', isLab ? 'lab' : 'clinic');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Call on logout.
|
||||
static Future<void> logoutUser() async {
|
||||
if (!_supported) return;
|
||||
try {
|
||||
await OneSignal.logout();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../api/pocketbase_client.dart';
|
||||
|
||||
typedef UnsubFn = Future<void> Function();
|
||||
|
||||
class RealtimeService {
|
||||
RealtimeService._();
|
||||
static final instance = RealtimeService._();
|
||||
|
||||
final _pb = PocketBaseClient.instance.pb;
|
||||
|
||||
UnsubFn watch(
|
||||
String collection, {
|
||||
String topic = '*',
|
||||
String filter = '',
|
||||
required void Function(RecordSubscriptionEvent) onEvent,
|
||||
}) {
|
||||
UnsubFn? cancel;
|
||||
|
||||
_pb.collection(collection).subscribe(topic, onEvent, filter: filter).then((fn) {
|
||||
cancel = fn;
|
||||
});
|
||||
|
||||
return () async {
|
||||
try {
|
||||
final fn = cancel;
|
||||
if (fn != null) {
|
||||
await fn();
|
||||
} else {
|
||||
await _pb.collection(collection).unsubscribe(topic);
|
||||
}
|
||||
} catch (_) {
|
||||
await _pb.collection(collection).unsubscribe(topic);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user