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
@@ -8,8 +8,12 @@ import '../../../core/providers/auth_provider.dart';
import '../../../core/providers/locale_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/job.dart';
import '../../../models/tenant.dart';
import '../../shared/location_completion_banner.dart';
import '../../shared/tenant_team_screen.dart';
import '../../shared/location_picker_sheet.dart';
import '../../shared/tenant_location_data.dart';
import '../connections/lab_connections_screen.dart';
class LabSettingsScreen extends ConsumerWidget {
@@ -29,6 +33,17 @@ class LabSettingsScreen extends ConsumerWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (tenant?.hasLocation != true) ...[
LocationCompletionBanner(
title: 'Konum eksik',
description:
'Laboratuvarınızın haritada görünmesi ve kliniklerin sizi yakın sonuçlarda bulabilmesi için koordinat kaydı tamamlanmalı.',
buttonLabel: 'Konumu Düzenle',
onTap: () => _showEditSheet(context, ref, tenant, s),
compact: true,
),
const SizedBox(height: 20),
],
// User card
_SectionHeader(title: s.userInfo),
_UserCard(profile: profile),
@@ -60,7 +75,9 @@ class LabSettingsScreen extends ConsumerWidget {
_InfoTileBadge(
icon: Icons.circle_outlined,
label: s.status,
value: tenant?.status == 'active' ? s.active : (tenant?.status ?? '-'),
value: tenant?.status == 'active'
? s.active
: (tenant?.status ?? '-'),
badgeColor: AppColors.success,
badgeBg: AppColors.successBg,
),
@@ -69,9 +86,42 @@ class LabSettingsScreen extends ConsumerWidget {
label: s.role,
value: _roleLabel(membership?.role, s),
),
_InfoTile(
icon: Icons.place_outlined,
label: 'Konum',
value: tenant?.locationLabel.isNotEmpty == true
? tenant!.locationLabel
: '-',
),
]),
const SizedBox(height: 20),
if (tenant != null && tenant.isLab) ...[
_SectionHeader(
title: 'İş Akışı',
action: canEdit
? IconButton(
icon: const Icon(Icons.tune_rounded,
size: 18, color: AppColors.accent),
tooltip: 'Akışı Düzenle',
onPressed: () => _showWorkflowSheet(context, ref, tenant),
)
: null,
),
_InfoCard(
children: [
_WorkflowPreviewTile(
enabledSteps: tenant.workflowOverrideSteps,
canEdit: canEdit,
onTap: canEdit
? () => _showWorkflowSheet(context, ref, tenant)
: null,
),
],
),
const SizedBox(height: 20),
],
// Connections
if (membership?.showConnections ?? false) ...[
_SectionHeader(title: s.connections),
@@ -107,7 +157,9 @@ class LabSettingsScreen extends ConsumerWidget {
onTap: () {
ref.read(authProvider.notifier).setActiveTenant(m);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
SnackBar(
content:
Text(s.tenantSelected(m.tenant.companyName))),
);
},
),
@@ -127,8 +179,7 @@ class LabSettingsScreen extends ConsumerWidget {
subtitle: s.teamSub,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const TenantTeamScreen()),
MaterialPageRoute(builder: (_) => const TenantTeamScreen()),
),
),
_NavTile(
@@ -155,6 +206,14 @@ class LabSettingsScreen extends ConsumerWidget {
subtitle: s.aiAssistantSub,
onTap: () => context.push(routeLabAi),
),
_NavTile(
icon: Icons.workspace_premium_outlined,
iconColor: AppColors.primary,
iconBg: const Color(0xFFEFF6FF),
title: 'Paketler ve AI Kredileri',
subtitle: 'Trial ve paket görünümünü incele',
onTap: () => context.push(routeWelcome),
),
]),
const SizedBox(height: 20),
],
@@ -167,7 +226,8 @@ class LabSettingsScreen extends ConsumerWidget {
iconColor: AppColors.accent,
iconBg: AppColors.inProgressBg,
title: s.appLanguage,
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
subtitle: _currentLanguageLabel(
ref.watch(localeProvider).languageCode, s),
onTap: () => _showLanguagePicker(context, ref, s),
),
]),
@@ -191,7 +251,8 @@ class LabSettingsScreen extends ConsumerWidget {
);
}
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
void _showEditSheet(
BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
if (tenant == null) return;
showModalBottomSheet(
context: context,
@@ -200,11 +261,12 @@ class LabSettingsScreen extends ConsumerWidget {
builder: (_) => _EditTenantSheet(
tenant: tenant,
s: s,
onSave: (name, currency) async {
onSave: (name, currency, location) async {
await ref.read(authProvider.notifier).updateTenantInfo(
tenantId: tenant.id,
companyName: name,
defaultCurrency: currency,
location: location,
);
},
),
@@ -219,6 +281,29 @@ class LabSettingsScreen extends ConsumerWidget {
);
}
void _showWorkflowSheet(
BuildContext context,
WidgetRef ref,
Tenant tenant,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _WorkflowSettingsSheet(
tenant: tenant,
onSave: (steps) async {
await ref.read(authProvider.notifier).updateTenantInfo(
tenantId: tenant.id,
companyName: tenant.companyName,
defaultCurrency: tenant.defaultCurrency,
workflowOverrides: steps.map((step) => step.value).toList(),
);
},
),
);
}
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
switch (kind) {
TenantKind.clinic => s.tenantKindClinic,
@@ -226,7 +311,8 @@ class LabSettingsScreen extends ConsumerWidget {
null => '-',
};
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
static String _currentLanguageLabel(String code, AppStrings s) =>
switch (code) {
'en' => s.languageEnglish,
'ru' => s.languageRussian,
'ar' => s.languageArabic,
@@ -334,7 +420,11 @@ class _EditTenantSheet extends StatefulWidget {
});
final Tenant tenant;
final AppStrings s;
final Future<void> Function(String companyName, String currency) onSave;
final Future<void> Function(
String companyName,
String currency,
TenantLocationData location,
) onSave;
@override
State<_EditTenantSheet> createState() => _EditTenantSheetState();
@@ -342,7 +432,11 @@ class _EditTenantSheet extends StatefulWidget {
class _EditTenantSheetState extends State<_EditTenantSheet> {
late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final TextEditingController _cityController;
late final TextEditingController _districtController;
late String _selectedCurrency;
late TenantLocationData _location;
bool _saving = false;
static const _currencies = [
@@ -358,26 +452,39 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
super.initState();
_nameController = TextEditingController(text: widget.tenant.companyName);
_selectedCurrency = widget.tenant.defaultCurrency;
_location = TenantLocationData.fromTenant(widget.tenant);
_addressController = TextEditingController(text: _location.address ?? '');
_cityController = TextEditingController(text: _location.city ?? '');
_districtController = TextEditingController(text: _location.district ?? '');
}
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
_cityController.dispose();
_districtController.dispose();
super.dispose();
}
Future<void> _submit() async {
final name = _nameController.text.trim();
if (name.isEmpty) return;
final location = _location.copyWith(
address: _addressController.text.trim(),
city: _cityController.text.trim(),
district: _districtController.text.trim(),
);
if (!location.hasDetails) return;
setState(() => _saving = true);
final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.onSave(name, _selectedCurrency);
await widget.onSave(name, _selectedCurrency, location);
navigator.pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
messenger
.showSnackBar(SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -431,7 +538,7 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
color: AppColors.textSecondary)),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _selectedCurrency,
initialValue: _selectedCurrency,
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
@@ -454,13 +561,91 @@ class _EditTenantSheetState extends State<_EditTenantSheet> {
if (v != null) setState(() => _selectedCurrency = v);
},
),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Konum',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
_location.fullLabel.isNotEmpty
? _location.fullLabel
: 'Henüz konum veya adres bilgisi girilmedi.',
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () async {
final picked = await showLocationPickerSheet(
context,
initialLocation: _location,
title: 'Laboratuvar Konumu',
);
if (picked != null) {
setState(() => _location = picked);
}
},
icon: const Icon(Icons.map_outlined),
label: const Text('Haritadan Konum Seç'),
),
const SizedBox(height: 12),
TextFormField(
controller: _addressController,
decoration: const InputDecoration(
labelText: 'Açık Adres',
hintText: 'Cadde, sokak, mahalle bilgisi',
),
maxLines: 2,
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextFormField(
controller: _cityController,
decoration: const InputDecoration(
labelText: 'Şehir',
),
textCapitalization: TextCapitalization.words,
),
),
const SizedBox(width: 10),
Expanded(
child: TextFormField(
controller: _districtController,
decoration: const InputDecoration(
labelText: 'İlçe',
),
textCapitalization: TextCapitalization.words,
),
),
],
),
],
),
),
const SizedBox(height: 20),
if (_saving)
const Center(
child: CircularProgressIndicator(color: AppColors.accent))
else
FilledButton(
onPressed: _submit,
onPressed: _saving ? null : _submit,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 48)),
child: Text(s.save),
@@ -593,7 +778,10 @@ class _InfoCard extends StatelessWidget {
children[i],
if (i < children.length - 1)
const Divider(
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
height: 1,
indent: 16,
endIndent: 16,
color: AppColors.border),
],
],
),
@@ -662,12 +850,11 @@ class _InfoTileBadge extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(label,
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted)),
style:
const TextStyle(fontSize: 11, color: AppColors.textMuted)),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: BorderRadius.circular(8),
@@ -704,8 +891,7 @@ class _NavTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container(
width: 36,
height: 36,
@@ -720,13 +906,170 @@ class _NavTile extends StatelessWidget {
? Text(subtitle!,
style: const TextStyle(color: AppColors.textSecondary))
: null,
trailing:
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
trailing: const Icon(Icons.chevron_right, color: AppColors.textSecondary),
onTap: onTap,
);
}
}
class _WorkflowPreviewTile extends StatelessWidget {
const _WorkflowPreviewTile({
required this.enabledSteps,
required this.canEdit,
this.onTap,
});
final List<JobStep> enabledSteps;
final bool canEdit;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final summary = enabledSteps.isEmpty
? 'Varsayılan preset akışı kullanılıyor.'
: 'Ekstra adımlar: ${enabledSteps.map((step) => step.label).join(', ')}';
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(9),
),
child:
const Icon(Icons.route_outlined, color: AppColors.accent, size: 18),
),
title: const Text(
'Ekstra Laboratuvar Adımları',
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
subtitle: Text(
summary,
style: const TextStyle(color: AppColors.textSecondary),
),
trailing: canEdit
? const Icon(Icons.chevron_right, color: AppColors.textSecondary)
: null,
onTap: onTap,
);
}
}
class _WorkflowSettingsSheet extends StatefulWidget {
const _WorkflowSettingsSheet({
required this.tenant,
required this.onSave,
});
final Tenant tenant;
final Future<void> Function(List<JobStep> steps) onSave;
@override
State<_WorkflowSettingsSheet> createState() => _WorkflowSettingsSheetState();
}
class _WorkflowSettingsSheetState extends State<_WorkflowSettingsSheet> {
late final Set<JobStep> _selected =
widget.tenant.workflowOverrideSteps.toSet();
bool _saving = false;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ekstra İş Adımları',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'Bunlar preset akışın üstüne eklenir. Bazı adımlar klinik onayı ister, bazıları laboratuvar içidir.',
style: TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Column(
children: optionalLabStepCatalog.map((step) {
final selected = _selected.contains(step);
return CheckboxListTile(
value: selected,
onChanged: (value) {
setState(() {
if (value == true) {
_selected.add(step);
} else {
_selected.remove(step);
}
});
},
title: Text(step.label),
subtitle: Text(
'${step.description} · ${step.requiresClinicApproval ? "Klinik onayı gerekir" : "Laboratuvar iç adımı"}',
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
);
}).toList(),
),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _saving
? null
: () async {
final navigator = Navigator.of(context);
setState(() => _saving = true);
try {
await widget.onSave(_selected.toList());
if (mounted) navigator.pop();
} finally {
if (mounted) setState(() => _saving = false);
}
},
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Kaydet'),
),
],
),
);
}
}
class _SignOutCard extends StatelessWidget {
const _SignOutCard({required this.ref, required this.s});
final WidgetRef ref;
@@ -747,16 +1090,14 @@ class _SignOutCard extends StatelessWidget {
],
),
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(9)),
child: const Icon(Icons.logout,
color: AppColors.cancelled, size: 18),
child: const Icon(Icons.logout, color: AppColors.cancelled, size: 18),
),
title: Text(s.signOut,
style: const TextStyle(