Add pricing entry flow and platform admin foundations

This commit is contained in:
egecankomur
2026-06-20 18:24:40 +03:00
parent 1d36ccdf30
commit ac42681f7e
44 changed files with 6567 additions and 1419 deletions
@@ -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'),
),
],
),
],
),
),
);
}
}