Add pricing entry flow and platform admin foundations
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../../core/api/pocketbase_client.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
|
||||
class ClinicConnectionsRepository {
|
||||
ClinicConnectionsRepository._();
|
||||
@@ -10,10 +11,10 @@ class ClinicConnectionsRepository {
|
||||
|
||||
Future<List<Connection>> listConnections(String clinicTenantId) async {
|
||||
final result = await _pb.collection('connections').getList(
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId"',
|
||||
expand: 'lab_tenant_id,clinic_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
filter: 'clinic_tenant_id = "$clinicTenantId"',
|
||||
expand: 'lab_tenant_id,clinic_tenant_id',
|
||||
perPage: 100,
|
||||
);
|
||||
return (result.items.map((r) => Connection.fromJson(r.toJson())).toList()
|
||||
..sort((a, b) => (b.dateCreated ?? '').compareTo(a.dateCreated ?? '')));
|
||||
}
|
||||
@@ -30,11 +31,26 @@ class ClinicConnectionsRepository {
|
||||
return Connection.fromJson(record.toJson());
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> searchLabs(String query) async {
|
||||
Future<List<Tenant>> searchLabs({
|
||||
String query = '',
|
||||
String? city,
|
||||
}) async {
|
||||
final normalizedQuery = query.trim().replaceAll('"', '\\"');
|
||||
final normalizedCity = (city ?? '').trim().replaceAll('"', '\\"');
|
||||
|
||||
final filterParts = ['kind = "lab"'];
|
||||
if (normalizedQuery.isNotEmpty) {
|
||||
filterParts.add(
|
||||
'(company_name ~ "$normalizedQuery" || city ~ "$normalizedQuery" || district ~ "$normalizedQuery")',
|
||||
);
|
||||
} else if (normalizedCity.isNotEmpty) {
|
||||
filterParts.add('city = "$normalizedCity"');
|
||||
}
|
||||
|
||||
final result = await _pb.collection('tenants').getList(
|
||||
filter: 'kind = "lab" && company_name ~ "$query"',
|
||||
perPage: 20,
|
||||
);
|
||||
return result.items.map((r) => r.toJson()).toList();
|
||||
filter: filterParts.join(' && '),
|
||||
perPage: 100,
|
||||
);
|
||||
return result.items.map((r) => Tenant.fromJson(r.toJson())).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:latlong2/latlong.dart' as ll;
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
import '../../../core/location/location_access_service.dart';
|
||||
import '../../../core/maps/open_free_map.dart';
|
||||
import '../../../core/providers/auth_provider.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/connection.dart';
|
||||
import '../../../models/tenant.dart';
|
||||
import 'clinic_connections_repository.dart';
|
||||
|
||||
class ClinicConnectionsScreen extends ConsumerStatefulWidget {
|
||||
@@ -27,19 +34,19 @@ class _ClinicConnectionsScreenState
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() {
|
||||
_future = ClinicConnectionsRepository.instance
|
||||
.listConnections(tenantId);
|
||||
_future = ClinicConnectionsRepository.instance.listConnections(tenantId);
|
||||
});
|
||||
}
|
||||
|
||||
void _showSearchDialog() {
|
||||
final clinicTenant = ref.read(authProvider).activeTenant!.tenant;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => _LabSearchDialog(
|
||||
clinicTenant: clinicTenant,
|
||||
onRequested: (labId, labName) async {
|
||||
Navigator.of(ctx).pop();
|
||||
final tenantId =
|
||||
ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
try {
|
||||
await ClinicConnectionsRepository.instance.requestConnection(
|
||||
clinicTenantId: tenantId,
|
||||
@@ -49,8 +56,8 @@ class _ClinicConnectionsScreenState
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$labName\'a bağlantı talebi gönderildi.')),
|
||||
content: Text('$labName\'a bağlantı talebi gönderildi.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -86,7 +93,8 @@ class _ClinicConnectionsScreenState
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent));
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Center(
|
||||
@@ -97,15 +105,20 @@ class _ClinicConnectionsScreenState
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled, size: 30),
|
||||
color: AppColors.cancelledBg,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
color: AppColors.cancelled,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Hata: ${snap.error}',
|
||||
style:
|
||||
const TextStyle(color: AppColors.textSecondary)),
|
||||
Text(
|
||||
'Hata: ${snap.error}',
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _load,
|
||||
@@ -126,18 +139,23 @@ class _ClinicConnectionsScreenState
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20)),
|
||||
child: const Icon(Icons.link_off,
|
||||
color: AppColors.inProgress, size: 32),
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.link_off,
|
||||
color: AppColors.inProgress,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Henüz bağlantı yok',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
@@ -161,25 +179,31 @@ class _ClinicConnectionsScreenState
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2))
|
||||
]),
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: Icon(Icons.science_outlined,
|
||||
color: statusColor, size: 22),
|
||||
color: statusBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.science_outlined,
|
||||
color: statusColor,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -189,17 +213,19 @@ class _ClinicConnectionsScreenState
|
||||
Text(
|
||||
conn.labName ?? 'Laboratuvar',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (conn.dateCreated != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(conn.dateCreated!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary),
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -259,6 +285,7 @@ class _ClinicConnectionsScreenState
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({required this.status});
|
||||
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
@@ -306,7 +333,12 @@ class _StatusChip extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _LabSearchDialog extends StatefulWidget {
|
||||
const _LabSearchDialog({required this.onRequested});
|
||||
const _LabSearchDialog({
|
||||
required this.clinicTenant,
|
||||
required this.onRequested,
|
||||
});
|
||||
|
||||
final Tenant clinicTenant;
|
||||
final void Function(String labId, String labName) onRequested;
|
||||
|
||||
@override
|
||||
@@ -314,128 +346,687 @@ class _LabSearchDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LabSearchDialogState extends State<_LabSearchDialog> {
|
||||
static const _fallbackCenter = LatLng(41.0082, 28.9784);
|
||||
static const _defaultZoom = 10.5;
|
||||
|
||||
final _distance = const ll.Distance();
|
||||
final _searchController = TextEditingController();
|
||||
List<Map<String, dynamic>> _results = [];
|
||||
Timer? _searchDebounce;
|
||||
List<_LabSearchItem> _results = [];
|
||||
bool _isLoading = false;
|
||||
bool _searched = false;
|
||||
String? _error;
|
||||
String? _selectedLabId;
|
||||
LatLng? _devicePoint;
|
||||
bool _resolvingDeviceLocation = false;
|
||||
MapLibreMapController? _mapController;
|
||||
bool _styleReady = false;
|
||||
|
||||
LatLng get _clinicPoint => LatLng(
|
||||
widget.clinicTenant.latitude ?? _fallbackCenter.latitude,
|
||||
widget.clinicTenant.longitude ?? _fallbackCenter.longitude,
|
||||
);
|
||||
|
||||
bool get _hasClinicLocation => widget.clinicTenant.hasLocation;
|
||||
LatLng? get _searchAnchorPoint =>
|
||||
_devicePoint ?? (_hasClinicLocation ? _clinicPoint : null);
|
||||
|
||||
_LabSearchItem? get _selectedLab {
|
||||
for (final item in _results) {
|
||||
if (item.tenant.id == _selectedLabId) return item;
|
||||
}
|
||||
return _results.isNotEmpty ? _results.first : null;
|
||||
}
|
||||
|
||||
List<_LabSearchItem> get _mappedLabs =>
|
||||
_results.where((item) => item.tenant.hasLocation).toList();
|
||||
|
||||
List<_LabSearchItem> get _legacyLabs =>
|
||||
_results.where((item) => !item.tenant.hasLocation).toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _search());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_searched = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final results =
|
||||
await ClinicConnectionsRepository.instance.searchLabs(query);
|
||||
final results = await ClinicConnectionsRepository.instance.searchLabs(
|
||||
query: query,
|
||||
city: widget.clinicTenant.city,
|
||||
);
|
||||
final mapped = results
|
||||
.map(
|
||||
(lab) => _LabSearchItem(
|
||||
tenant: lab,
|
||||
distanceKm: _distanceFor(lab),
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final aDistance = a.distanceKm ?? double.infinity;
|
||||
final bDistance = b.distanceKm ?? double.infinity;
|
||||
final compareDistance = aDistance.compareTo(bDistance);
|
||||
if (compareDistance != 0) return compareDistance;
|
||||
return a.tenant.companyName.compareTo(b.tenant.companyName);
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_results = results;
|
||||
_results = mapped;
|
||||
_selectedLabId = mapped.isNotEmpty ? mapped.first.tenant.id : null;
|
||||
_isLoading = false;
|
||||
});
|
||||
await _refreshMarkers();
|
||||
await _moveMapToResults();
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e')),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
double? _distanceFor(Tenant lab) {
|
||||
final anchor = _searchAnchorPoint;
|
||||
if (anchor == null || !lab.hasLocation) return null;
|
||||
final meters = _distance(
|
||||
ll.LatLng(anchor.latitude, anchor.longitude),
|
||||
ll.LatLng(lab.latitude!, lab.longitude!),
|
||||
);
|
||||
return meters / 1000;
|
||||
}
|
||||
|
||||
Future<void> _moveMapToResults() async {
|
||||
final controller = _mapController;
|
||||
if (controller == null) return;
|
||||
|
||||
final selected = _selectedLab;
|
||||
if (selected?.tenant.hasLocation == true) {
|
||||
await controller.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(
|
||||
LatLng(selected!.tenant.latitude!, selected.tenant.longitude!),
|
||||
12.8,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final fallbackPoint = _searchAnchorPoint ?? _clinicPoint;
|
||||
await controller.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(fallbackPoint, _defaultZoom),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshMarkers() async {
|
||||
final controller = _mapController;
|
||||
if (controller == null || !_styleReady) return;
|
||||
|
||||
await controller.clearCircles();
|
||||
|
||||
final circles = <CircleOptions>[
|
||||
if (_hasClinicLocation)
|
||||
CircleOptions(
|
||||
geometry: _clinicPoint,
|
||||
circleRadius: 7,
|
||||
circleColor: '#111827',
|
||||
circleStrokeWidth: 2,
|
||||
circleStrokeColor: '#FFFFFF',
|
||||
),
|
||||
..._mappedLabs.map(
|
||||
(item) => CircleOptions(
|
||||
geometry: LatLng(
|
||||
item.tenant.latitude!,
|
||||
item.tenant.longitude!,
|
||||
),
|
||||
circleRadius: item.tenant.id == _selectedLabId ? 8 : 6,
|
||||
circleColor: item.tenant.id == _selectedLabId ? '#4F46E5' : '#0F766E',
|
||||
circleStrokeWidth: 2,
|
||||
circleStrokeColor: '#FFFFFF',
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
if (circles.isNotEmpty) {
|
||||
await controller.addCircles(circles);
|
||||
}
|
||||
}
|
||||
|
||||
void _queueSearch(String _) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 350), _search);
|
||||
}
|
||||
|
||||
Future<void> _useDeviceLocationForSearch() async {
|
||||
setState(() {
|
||||
_resolvingDeviceLocation = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final position = await LocationAccessService.getCurrentPosition();
|
||||
_devicePoint = LatLng(position.latitude, position.longitude);
|
||||
await _search();
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _resolvingDeviceLocation = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _selectLab(_LabSearchItem item) {
|
||||
setState(() => _selectedLabId = item.tenant.id);
|
||||
unawaited(_refreshMarkers());
|
||||
unawaited(_moveMapToResults());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Laboratuvar Bul'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
final selectedLab = _selectedLab;
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
height: MediaQuery.sizeOf(context).height * 0.88,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Lab adı ile arayın...',
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Laboratuvar Bul',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _search(),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Lab adı, şehir veya ilçe ile arayın...',
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: _isLoading
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
)
|
||||
: (_searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_queueSearch('');
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
)
|
||||
: null),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
_queueSearch(value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_searchAnchorPoint != null
|
||||
? Icons.near_me_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_devicePoint != null
|
||||
? 'Sonuçlar aktif konumunuza göre yakın olandan sıralanır.'
|
||||
: (_hasClinicLocation
|
||||
? 'Sonuçlar kliniğin konumuna göre yakın olandan sıralanır.'
|
||||
: 'Aktif konumunuzu kullanarak yakın arama yapabilir veya isimle arayabilirsiniz.'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _resolvingDeviceLocation
|
||||
? null
|
||||
: _useDeviceLocationForSearch,
|
||||
icon: _resolvingDeviceLocation
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.my_location_rounded, size: 18),
|
||||
label: Text(
|
||||
_devicePoint == null
|
||||
? 'Aktif Konumumla Ara'
|
||||
: 'Aktif Konumu Yenile',
|
||||
),
|
||||
),
|
||||
if (_devicePoint != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _devicePoint = null);
|
||||
unawaited(_search());
|
||||
},
|
||||
child: const Text('Klinik Konumuna Dön'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Stack(
|
||||
children: [
|
||||
MapLibreMap(
|
||||
styleString: OpenFreeMap.libertyStyle,
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: _clinicPoint,
|
||||
zoom: _defaultZoom,
|
||||
),
|
||||
onMapCreated: (controller) {
|
||||
_mapController = controller;
|
||||
},
|
||||
onStyleLoadedCallback: () async {
|
||||
_styleReady = true;
|
||||
await _refreshMarkers();
|
||||
},
|
||||
compassEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
rotateGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
myLocationTrackingMode: MyLocationTrackingMode.none,
|
||||
),
|
||||
if (selectedLab != null)
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
child: _SelectedLabCard(
|
||||
item: selectedLab,
|
||||
onRequest: () => widget.onRequested(
|
||||
selectedLab.tenant.id,
|
||||
selectedLab.tenant.companyName,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: _search,
|
||||
child: const Text('Ara'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
)
|
||||
else if (_searched && _results.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('Sonuç bulunamadı',
|
||||
style: TextStyle(color: AppColors.textSecondary)),
|
||||
)
|
||||
else if (_results.isNotEmpty)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lab = _results[index];
|
||||
final name =
|
||||
lab['company_name'] as String? ?? 'Lab';
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
child: const Icon(Icons.science_outlined,
|
||||
color: AppColors.inProgress, size: 18),
|
||||
),
|
||||
title: Text(name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary)),
|
||||
subtitle: lab['member_number'] != null
|
||||
? Text('No: ${lab['member_number']}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary))
|
||||
: null,
|
||||
onTap: () =>
|
||||
widget.onRequested(lab['id'] as String, name),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||||
child: _buildResultsList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('İptal'),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsList() {
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Hata: $_error',
|
||||
style: const TextStyle(color: AppColors.cancelled),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isLoading && !_searched) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.accent),
|
||||
);
|
||||
}
|
||||
|
||||
if (_searched && _results.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Bu kriterlerde laboratuvar bulunamadı.',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final children = <Widget>[
|
||||
if (_mappedLabs.isNotEmpty) ...[
|
||||
const _ResultsSectionHeader(
|
||||
title: 'Haritadaki Laboratuvarlar',
|
||||
subtitle: 'Konumu tanımlı işletmeler',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final item in _mappedLabs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _LabResultTile(
|
||||
item: item,
|
||||
isSelected: item.tenant.id == _selectedLabId,
|
||||
badgeText: item.distanceKm != null
|
||||
? '${item.distanceKm!.toStringAsFixed(1)} km'
|
||||
: 'Haritada',
|
||||
onTap: () => _selectLab(item),
|
||||
onRequest: () => widget.onRequested(
|
||||
item.tenant.id,
|
||||
item.tenant.companyName,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_legacyLabs.isNotEmpty) ...[
|
||||
if (_mappedLabs.isNotEmpty) const SizedBox(height: 8),
|
||||
const _ResultsSectionHeader(
|
||||
title: 'İsimle Bulunan İşletmeler',
|
||||
subtitle: 'Eski kayıtlar için konum zorunlu değil',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (final item in _legacyLabs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _LabResultTile(
|
||||
item: item,
|
||||
isSelected: item.tenant.id == _selectedLabId,
|
||||
badgeText: 'Konum bekleniyor',
|
||||
onTap: () => _selectLab(item),
|
||||
onRequest: () => widget.onRequested(
|
||||
item.tenant.id,
|
||||
item.tenant.companyName,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
return ListView(children: children);
|
||||
}
|
||||
}
|
||||
|
||||
class _LabSearchItem {
|
||||
const _LabSearchItem({
|
||||
required this.tenant,
|
||||
required this.distanceKm,
|
||||
});
|
||||
|
||||
final Tenant tenant;
|
||||
final double? distanceKm;
|
||||
}
|
||||
|
||||
class _SelectedLabCard extends StatelessWidget {
|
||||
const _SelectedLabCard({
|
||||
required this.item,
|
||||
required this.onRequest,
|
||||
});
|
||||
|
||||
final _LabSearchItem item;
|
||||
final VoidCallback onRequest;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.science_outlined,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.tenant.companyName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.tenant.locationLabel.isNotEmpty
|
||||
? item.tenant.locationLabel
|
||||
: 'Adres bilgisi henüz girilmemiş',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: onRequest,
|
||||
child: const Text('Bağlan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResultsSectionHeader extends StatelessWidget {
|
||||
const _ResultsSectionHeader({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LabResultTile extends StatelessWidget {
|
||||
const _LabResultTile({
|
||||
required this.item,
|
||||
required this.isSelected,
|
||||
required this.badgeText,
|
||||
required this.onTap,
|
||||
required this.onRequest,
|
||||
});
|
||||
|
||||
final _LabSearchItem item;
|
||||
final bool isSelected;
|
||||
final String badgeText;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onRequest;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.inProgressBg : AppColors.background,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.accent : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
item.tenant.hasLocation
|
||||
? Icons.location_searching_rounded
|
||||
: Icons.manage_search_rounded,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.tenant.companyName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (item.tenant.locationLabel.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.tenant.locationLabel,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.tenant.memberNumber.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Üye No: ${item.tenant.memberNumber}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
badgeText,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: onRequest,
|
||||
child: const Text('Bağlan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user