1033 lines
32 KiB
Dart
1033 lines
32 KiB
Dart
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 {
|
||
const ClinicConnectionsScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<ClinicConnectionsScreen> createState() =>
|
||
_ClinicConnectionsScreenState();
|
||
}
|
||
|
||
class _ClinicConnectionsScreenState
|
||
extends ConsumerState<ClinicConnectionsScreen> {
|
||
late Future<List<Connection>> _future;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
}
|
||
|
||
void _load() {
|
||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||
setState(() {
|
||
_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;
|
||
try {
|
||
await ClinicConnectionsRepository.instance.requestConnection(
|
||
clinicTenantId: tenantId,
|
||
labTenantId: labId,
|
||
);
|
||
_load();
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('$labName\'a bağlantı talebi gönderildi.'),
|
||
),
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Hata: $e')),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Bağlantılar'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.add_link),
|
||
tooltip: 'Laboratuvar Bul',
|
||
onPressed: _showSearchDialog,
|
||
),
|
||
],
|
||
),
|
||
body: RefreshIndicator(
|
||
color: AppColors.accent,
|
||
onRefresh: () async => _load(),
|
||
child: FutureBuilder<List<Connection>>(
|
||
future: _future,
|
||
builder: (ctx, snap) {
|
||
if (snap.connectionState == ConnectionState.waiting) {
|
||
return const Center(
|
||
child: CircularProgressIndicator(color: AppColors.accent),
|
||
);
|
||
}
|
||
if (snap.hasError) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
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,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Hata: ${snap.error}',
|
||
style: const TextStyle(color: AppColors.textSecondary),
|
||
),
|
||
const SizedBox(height: 12),
|
||
FilledButton.icon(
|
||
onPressed: _load,
|
||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||
label: const Text('Tekrar Dene'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
final connections = snap.data!;
|
||
if (connections.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
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,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
FilledButton.icon(
|
||
onPressed: _showSearchDialog,
|
||
icon: const Icon(Icons.search),
|
||
label: const Text('Laboratuvar Bul'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||
itemCount: connections.length,
|
||
itemBuilder: (context, index) {
|
||
final conn = connections[index];
|
||
final statusColor = _statusColor(conn.status);
|
||
final statusBg = _statusBg(conn.status);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
conn.labName ?? 'Laboratuvar',
|
||
style: const TextStyle(
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
_StatusChip(status: conn.status),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
floatingActionButton: FloatingActionButton.extended(
|
||
onPressed: _showSearchDialog,
|
||
backgroundColor: AppColors.accent,
|
||
foregroundColor: Colors.white,
|
||
icon: const Icon(Icons.search),
|
||
label: const Text('Laboratuvar Bul'),
|
||
),
|
||
);
|
||
}
|
||
|
||
Color _statusColor(ConnectionStatus s) {
|
||
switch (s) {
|
||
case ConnectionStatus.pending:
|
||
return AppColors.pending;
|
||
case ConnectionStatus.approved:
|
||
return AppColors.success;
|
||
case ConnectionStatus.rejected:
|
||
return AppColors.cancelled;
|
||
}
|
||
}
|
||
|
||
Color _statusBg(ConnectionStatus s) {
|
||
switch (s) {
|
||
case ConnectionStatus.pending:
|
||
return AppColors.pendingBg;
|
||
case ConnectionStatus.approved:
|
||
return AppColors.successBg;
|
||
case ConnectionStatus.rejected:
|
||
return AppColors.cancelledBg;
|
||
}
|
||
}
|
||
|
||
String _formatDate(String dateStr) {
|
||
try {
|
||
final d = DateTime.parse(dateStr);
|
||
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
|
||
} catch (_) {
|
||
return dateStr;
|
||
}
|
||
}
|
||
}
|
||
|
||
class _StatusChip extends StatelessWidget {
|
||
const _StatusChip({required this.status});
|
||
|
||
final ConnectionStatus status;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final color = _color(status);
|
||
final bg = _bg(status);
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
status.label,
|
||
style: TextStyle(
|
||
color: color,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Color _color(ConnectionStatus s) {
|
||
switch (s) {
|
||
case ConnectionStatus.pending:
|
||
return AppColors.pending;
|
||
case ConnectionStatus.approved:
|
||
return AppColors.success;
|
||
case ConnectionStatus.rejected:
|
||
return AppColors.cancelled;
|
||
}
|
||
}
|
||
|
||
Color _bg(ConnectionStatus s) {
|
||
switch (s) {
|
||
case ConnectionStatus.pending:
|
||
return AppColors.pendingBg;
|
||
case ConnectionStatus.approved:
|
||
return AppColors.successBg;
|
||
case ConnectionStatus.rejected:
|
||
return AppColors.cancelledBg;
|
||
}
|
||
}
|
||
}
|
||
|
||
class _LabSearchDialog extends StatefulWidget {
|
||
const _LabSearchDialog({
|
||
required this.clinicTenant,
|
||
required this.onRequested,
|
||
});
|
||
|
||
final Tenant clinicTenant;
|
||
final void Function(String labId, String labName) onRequested;
|
||
|
||
@override
|
||
State<_LabSearchDialog> createState() => _LabSearchDialogState();
|
||
}
|
||
|
||
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();
|
||
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();
|
||
|
||
setState(() {
|
||
_isLoading = true;
|
||
_searched = true;
|
||
_error = null;
|
||
});
|
||
|
||
try {
|
||
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 = mapped;
|
||
_selectedLabId = mapped.isNotEmpty ? mapped.first.tenant.id : null;
|
||
_isLoading = false;
|
||
});
|
||
await _refreshMarkers();
|
||
await _moveMapToResults();
|
||
} catch (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) {
|
||
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(
|
||
children: [
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
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(height: 14),
|
||
Expanded(
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||
child: _buildResultsList(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|