Files
lab-app/lib/features/clinic/jobs/new_job_screen.dart
T
Emre Emir 8bbc9dbff2 Initial commit: DLS - Dental Lab System
- Flutter + PocketBase dental lab management system
- Clinic & lab dashboards, job tracking, patient management
- Product catalog, finance tracking, multi-language support
- AI assistant integration, realtime notifications
- Windows installer (Inno Setup) included
- Developed by kovakyazilim.com
2026-06-11 15:57:31 +03:00

1068 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:math';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import '../../../core/api/pocketbase_client.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/clinic_discount.dart';
import '../../../models/job.dart';
import '../../../models/patient.dart';
import '../../../models/prosthetic_product.dart';
import '../../lab/discounts/discount_repository.dart';
import '../../lab/products/lab_products_repository.dart';
import 'clinic_jobs_repository.dart';
import '../patients/clinic_patients_repository.dart';
String _mimeFromExt(String ext) => switch (ext) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'webp' => 'image/webp',
'pdf' => 'application/pdf',
'stl' => 'model/stl',
'obj' => 'model/obj',
'ply' => 'model/ply',
'zip' => 'application/zip',
'mp3' => 'audio/mpeg',
'mp4' => 'video/mp4',
'opus' => 'audio/opus',
_ => 'application/octet-stream',
};
class NewJobScreen extends ConsumerStatefulWidget {
const NewJobScreen({super.key});
@override
ConsumerState<NewJobScreen> createState() => _NewJobScreenState();
}
class _NewJobScreenState extends ConsumerState<NewJobScreen> {
final _formKey = GlobalKey<FormState>();
// Form fields
Map<String, dynamic>? _selectedLab;
Patient? _selectedPatient;
final _patientCodeController = TextEditingController();
ProstheticType? _selectedProstheticType;
final Set<int> _selectedTeeth = {};
final _colorController = TextEditingController();
final _descriptionController = TextEditingController();
DateTime? _dueDate;
bool _provaRequired = true;
// State
List<Map<String, dynamic>> _labs = [];
bool _labsLoading = true;
bool _isSubmitting = false;
String? _labsError;
// File upload
final List<PlatformFile> _pendingFiles = [];
// Patient search
bool _showPatientSearch = false;
final _patientSearchController = TextEditingController();
List<Patient> _patientResults = [];
bool _patientSearchLoading = false;
// Price preview
ProstheticProduct? _labProduct;
double? _effectivePrice;
bool _priceLoading = false;
@override
void initState() {
super.initState();
_loadLabs();
}
@override
void dispose() {
_patientCodeController.dispose();
_colorController.dispose();
_descriptionController.dispose();
_patientSearchController.dispose();
super.dispose();
}
Future<void> _loadLabs() async {
setState(() {
_labsLoading = true;
_labsError = null;
});
try {
final tenantId =
ref.read(authProvider).activeTenant!.tenant.id;
final labs =
await ClinicJobsRepository.instance.listApprovedLabs(tenantId);
setState(() {
_labs = labs;
_labsLoading = false;
});
} catch (e) {
setState(() {
_labsError = e.toString();
_labsLoading = false;
});
}
}
Future<void> _fetchPrice() async {
if (_selectedLab == null || _selectedProstheticType == null) {
setState(() { _labProduct = null; _effectivePrice = null; });
return;
}
final labId = _selectedLab!['id'] as String;
final ptValue = _selectedProstheticType!.value;
final clinicTenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() => _priceLoading = true);
try {
final products = await LabProductsRepository.instance.listProducts(labId, isActive: true);
ProstheticProduct? product;
try {
product = products.firstWhere((p) => p.prostheticType == ptValue);
} catch (_) {
product = null;
}
if (product == null || product.unitPrice == null) {
setState(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; });
return;
}
final discounts = await DiscountRepository.instance.listDiscounts(labId);
final applicable = discounts.where((d) =>
d.isActive &&
(d.appliesToAll || d.clinicTenantId == clinicTenantId) &&
(d.appliesToAllTypes || d.prostheticType == ptValue)
).toList();
double price = product.unitPrice!;
for (final d in applicable) {
price = d.discountType == DiscountType.percentage
? price * (1 - d.discountValue / 100)
: price - d.discountValue;
}
setState(() {
_labProduct = product;
_effectivePrice = price.clamp(0, double.infinity);
_priceLoading = false;
});
} catch (_) {
setState(() { _labProduct = null; _effectivePrice = null; _priceLoading = false; });
}
}
Future<void> _searchPatients(String query) async {
if (query.trim().isEmpty) {
setState(() => _patientResults = []);
return;
}
setState(() => _patientSearchLoading = true);
try {
final tenantId =
ref.read(authProvider).activeTenant!.tenant.id;
final results = await ClinicPatientsRepository.instance
.listPatients(tenantId, search: query, limit: 10);
setState(() {
_patientResults = results;
_patientSearchLoading = false;
});
} catch (_) {
setState(() => _patientSearchLoading = false);
}
}
Future<void> _pickDueDate() async {
final pickedDate = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 7)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (pickedDate == null || !mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 17, minute: 0),
);
if (!mounted) return;
setState(() {
_dueDate = DateTime(
pickedDate.year, pickedDate.month, pickedDate.day,
pickedTime?.hour ?? 17, pickedTime?.minute ?? 0,
);
});
}
String _generateProtocolNo() {
final now = DateTime.now();
final date =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
const chars = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ';
final rand = List.generate(4, (_) => chars[Random().nextInt(chars.length)]).join();
return 'PR-$date-$rand';
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_selectedLab == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lütfen bir laboratuvar seçin.')),
);
return;
}
if (_selectedProstheticType == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lütfen protez türünü seçin.')),
);
return;
}
if (_selectedTeeth.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('En az bir diş seçmelisiniz.')),
);
return;
}
setState(() => _isSubmitting = true);
try {
final auth = ref.read(authProvider);
final tenantId = auth.activeTenant!.tenant.id;
final rawCode = _patientCodeController.text.trim();
final protocolNo = rawCode.isNotEmpty ? rawCode : _generateProtocolNo();
final job = await ClinicJobsRepository.instance.createJob(
clinicTenantId: tenantId,
labTenantId: _selectedLab!['id'] as String,
patientCode: protocolNo,
prostheticId: '',
prostheticType: _selectedProstheticType!,
teeth: _selectedTeeth.map((t) => t.toString()).toList()..sort(),
patientId: _selectedPatient?.id,
color: _colorController.text.trim().isNotEmpty
? _colorController.text.trim()
: null,
description: _descriptionController.text.trim().isNotEmpty
? _descriptionController.text.trim()
: null,
dueDate: _dueDate?.toIso8601String(),
provaRequired: _provaRequired,
);
// Upload pending files
if (_pendingFiles.isNotEmpty) {
final pb = PocketBaseClient.instance.pb;
final token = pb.authStore.token;
final uploaderId = (pb.authStore.record?.id) ?? (auth.profile?.id ?? '');
for (final file in _pendingFiles) {
final bytes = file.bytes;
if (bytes == null) continue;
final ext = (file.extension ?? '').toLowerCase();
final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply')
? 'scan'
: (ext == 'pdf') ? 'document' : 'image';
final mimeType = _mimeFromExt(ext);
final req = http.MultipartRequest(
'POST',
Uri.parse('https://pocket.kovaksoft.com/api/collections/job_files/records'),
)
..headers['Authorization'] = 'Bearer $token'
..fields['job_id'] = job.id
..fields['clinic_tenant_id'] = job.clinicTenantId
..fields['lab_tenant_id'] = job.labTenantId
..fields['uploaded_by'] = uploaderId
..fields['kind'] = kind
..fields['name'] = file.name
..fields['size'] = bytes.length.toString()
..fields['mime_type'] = mimeType
..files.add(http.MultipartFile.fromBytes(
'file',
bytes,
filename: file.name,
));
final response = await req.send();
if (response.statusCode < 200 || response.statusCode >= 300) {
final body = await response.stream.bytesToString();
debugPrint('File upload failed: ${response.statusCode} $body');
}
}
}
if (mounted) {
context.go('/clinic/jobs/${job.id}');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Yeni İş')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Lab selection
_SectionLabel(label: 'Laboratuvar *'),
if (_labsLoading)
const Center(child: CircularProgressIndicator())
else if (_labsError != null)
Row(
children: [
Text('Hata: $_labsError',
style: const TextStyle(color: AppColors.cancelled)),
TextButton(
onPressed: _loadLabs,
child: const Text('Tekrar Dene'),
),
],
)
else
DropdownButtonFormField<Map<String, dynamic>>(
initialValue: _selectedLab,
decoration: const InputDecoration(
hintText: 'Laboratuvar seçin',
),
items: _labs
.map(
(lab) => DropdownMenuItem(
value: lab,
child: Text(lab['company_name'] as String? ?? ''),
),
)
.toList(),
onChanged: (val) {
setState(() => _selectedLab = val);
_fetchPrice();
},
validator: (val) =>
val == null ? 'Laboratuvar seçimi zorunludur' : null,
),
const SizedBox(height: 16),
// Protocol number
_SectionLabel(label: 'Protokol No (İsteğe Bağlı)'),
Row(
children: [
Expanded(
child: TextFormField(
controller: _patientCodeController,
decoration: InputDecoration(
hintText: 'Boş bırakılırsa otomatik üretilir',
suffixIcon: _selectedPatient != null
? const Icon(Icons.person,
color: AppColors.success)
: null,
),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
setState(() => _showPatientSearch = !_showPatientSearch);
},
icon: const Icon(Icons.search),
label: const Text('Ara'),
),
],
),
// Patient search panel
if (_showPatientSearch) ...[
const SizedBox(height: 8),
TextField(
controller: _patientSearchController,
decoration: const InputDecoration(
hintText: 'Ad, soyad veya kod ile arayın...',
prefixIcon: Icon(Icons.search),
),
onChanged: _searchPatients,
),
if (_patientSearchLoading)
const Padding(
padding: EdgeInsets.all(8),
child: Center(child: CircularProgressIndicator()),
),
..._patientResults.map(
(p) => ListTile(
dense: true,
leading: const Icon(Icons.person_outline),
title: Text(p.displayName),
subtitle: Text(p.patientCode),
onTap: () {
setState(() {
_selectedPatient = p;
_patientCodeController.text = p.patientCode;
_showPatientSearch = false;
_patientSearchController.clear();
_patientResults.clear();
});
},
),
),
],
const SizedBox(height: 16),
// Prosthetic type
_SectionLabel(label: 'Protez Türü *'),
DropdownButtonFormField<ProstheticType>(
initialValue: _selectedProstheticType,
decoration: const InputDecoration(
hintText: 'Protez türü seçin',
),
items: ProstheticType.values
.map(
(pt) => DropdownMenuItem(
value: pt,
child: Text(pt.label),
),
)
.toList(),
onChanged: (val) {
setState(() => _selectedProstheticType = val);
_fetchPrice();
},
validator: (val) =>
val == null ? 'Protez türü zorunludur' : null,
),
// Price preview
if (_priceLoading)
const Padding(
padding: EdgeInsets.only(top: 8),
child: Row(children: [
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 1.5)),
SizedBox(width: 8),
Text('Fiyat yükleniyor...', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
]),
)
else if (_labProduct != null && _effectivePrice != null) ...[
const SizedBox(height: 8),
_PricePreviewChip(
product: _labProduct!,
effectivePrice: _effectivePrice!,
),
],
const SizedBox(height: 12),
// Prova flag
_ProvaToggle(
value: _provaRequired,
prostheticType: _selectedProstheticType,
onChanged: (v) => setState(() => _provaRequired = v),
),
const SizedBox(height: 16),
// Teeth selection
_SectionLabel(
label: 'Dişler * (${_selectedTeeth.length} seçili)',
),
const SizedBox(height: 6),
// Bulk select row
_TeethBulkBar(
selectedTeeth: _selectedTeeth,
onSelectAll: () => setState(() {
_selectedTeeth.addAll([
for (int i = 11; i <= 18; i++) i,
for (int i = 21; i <= 28; i++) i,
for (int i = 31; i <= 38; i++) i,
for (int i = 41; i <= 48; i++) i,
]);
}),
onSelectUpper: () => setState(() {
final upper = {...[for (int i = 11; i <= 18; i++) i], ...[for (int i = 21; i <= 28; i++) i]};
if (upper.every(_selectedTeeth.contains)) {
_selectedTeeth.removeAll(upper);
} else {
_selectedTeeth.addAll(upper);
}
}),
onSelectLower: () => setState(() {
final lower = {...[for (int i = 31; i <= 38; i++) i], ...[for (int i = 41; i <= 48; i++) i]};
if (lower.every(_selectedTeeth.contains)) {
_selectedTeeth.removeAll(lower);
} else {
_selectedTeeth.addAll(lower);
}
}),
onClear: () => setState(() => _selectedTeeth.clear()),
),
const SizedBox(height: 8),
_TeethGrid(
selectedTeeth: _selectedTeeth,
onToggle: (t) {
setState(() {
if (_selectedTeeth.contains(t)) {
_selectedTeeth.remove(t);
} else {
_selectedTeeth.add(t);
}
});
},
),
const SizedBox(height: 16),
// Color (optional)
_SectionLabel(label: 'Renk (İsteğe Bağlı)'),
TextFormField(
controller: _colorController,
decoration: const InputDecoration(
hintText: 'Ör: A2, B3',
),
),
const SizedBox(height: 16),
// Description (optional)
_SectionLabel(label: 'Açıklama (İsteğe Bağlı)'),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
hintText: 'Ek notlar...',
),
minLines: 2,
maxLines: 4,
),
const SizedBox(height: 16),
// Due date (optional)
_SectionLabel(label: 'Son Tarih (İsteğe Bağlı)'),
InkWell(
onTap: _pickDueDate,
child: InputDecorator(
decoration: const InputDecoration(
suffixIcon: Icon(Icons.calendar_today),
),
child: Text(
_dueDate != null
? '${_dueDate!.day.toString().padLeft(2, '0')}.${_dueDate!.month.toString().padLeft(2, '0')}.${_dueDate!.year} ${_dueDate!.hour.toString().padLeft(2, '0')}:${_dueDate!.minute.toString().padLeft(2, '0')}'
: 'Tarih ve saat seçin',
style: _dueDate != null
? null
: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
const SizedBox(height: 16),
// File attachments (optional)
_SectionLabel(label: 'Dosya Ekle (İsteğe Bağlı)'),
_FilePicker(
files: _pendingFiles,
onAdd: () async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
withData: true,
);
if (result != null) {
setState(() => _pendingFiles.addAll(result.files));
}
},
onRemove: (i) => setState(() => _pendingFiles.removeAt(i)),
),
const SizedBox(height: 24),
// Submit button
if (_isSubmitting)
const Center(child: CircularProgressIndicator())
else
FilledButton.icon(
onPressed: _submit,
icon: const Icon(Icons.check),
label: const Text('İş Oluştur'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
),
),
const SizedBox(height: 24),
],
),
),
);
}
}
class _TeethBulkBar extends StatelessWidget {
const _TeethBulkBar({
required this.selectedTeeth,
required this.onSelectAll,
required this.onSelectUpper,
required this.onSelectLower,
required this.onClear,
});
final Set<int> selectedTeeth;
final VoidCallback onSelectAll;
final VoidCallback onSelectUpper;
final VoidCallback onSelectLower;
final VoidCallback onClear;
bool _allUpperSelected() {
final upper = [for (int i = 11; i <= 18; i++) i, for (int i = 21; i <= 28; i++) i];
return upper.every(selectedTeeth.contains);
}
bool _allLowerSelected() {
final lower = [for (int i = 31; i <= 38; i++) i, for (int i = 41; i <= 48; i++) i];
return lower.every(selectedTeeth.contains);
}
@override
Widget build(BuildContext context) {
final allSelected = _allUpperSelected() && _allLowerSelected();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_BulkChip(
label: 'Tüm Dişler',
active: allSelected,
onTap: allSelected ? onClear : onSelectAll,
icon: Icons.select_all_rounded,
),
const SizedBox(width: 6),
_BulkChip(
label: 'Üst Çene',
active: _allUpperSelected(),
onTap: onSelectUpper,
icon: Icons.arrow_upward_rounded,
),
const SizedBox(width: 6),
_BulkChip(
label: 'Alt Çene',
active: _allLowerSelected(),
onTap: onSelectLower,
icon: Icons.arrow_downward_rounded,
),
const SizedBox(width: 6),
if (selectedTeeth.isNotEmpty)
_BulkChip(
label: 'Temizle',
active: false,
onTap: onClear,
icon: Icons.clear_rounded,
destructive: true,
),
],
),
);
}
}
class _BulkChip extends StatelessWidget {
const _BulkChip({
required this.label,
required this.active,
required this.onTap,
required this.icon,
this.destructive = false,
});
final String label;
final bool active;
final VoidCallback onTap;
final IconData icon;
final bool destructive;
@override
Widget build(BuildContext context) {
final color = destructive
? AppColors.cancelled
: active
? AppColors.accent
: AppColors.textSecondary;
final bg = destructive
? AppColors.cancelledBg
: active
? AppColors.inProgressBg
: AppColors.surfaceVariant;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: active && !destructive ? AppColors.accent : AppColors.border,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color),
),
],
),
),
);
}
}
class _TeethGrid extends StatelessWidget {
const _TeethGrid({
required this.selectedTeeth,
required this.onToggle,
});
final Set<int> selectedTeeth;
final ValueChanged<int> onToggle;
@override
Widget build(BuildContext context) {
// Upper jaw: 18-11, 21-28
// Lower jaw: 48-41, 31-38
final upperRight = List.generate(8, (i) => 18 - i); // 18..11
final upperLeft = List.generate(8, (i) => 21 + i); // 21..28
final lowerRight = List.generate(8, (i) => 48 - i); // 48..41
final lowerLeft = List.generate(8, (i) => 31 + i); // 31..38
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Upper jaw label
Text('Üst Çene',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
)),
const SizedBox(height: 4),
Row(
children: [
...upperRight.map((t) => _ToothButton(
tooth: t,
selected: selectedTeeth.contains(t),
onTap: () => onToggle(t),
)),
const VerticalDivider(width: 8),
...upperLeft.map((t) => _ToothButton(
tooth: t,
selected: selectedTeeth.contains(t),
onTap: () => onToggle(t),
)),
],
),
const SizedBox(height: 8),
// Lower jaw
Text('Alt Çene',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
)),
const SizedBox(height: 4),
Row(
children: [
...lowerRight.map((t) => _ToothButton(
tooth: t,
selected: selectedTeeth.contains(t),
onTap: () => onToggle(t),
)),
const VerticalDivider(width: 8),
...lowerLeft.map((t) => _ToothButton(
tooth: t,
selected: selectedTeeth.contains(t),
onTap: () => onToggle(t),
)),
],
),
],
);
}
}
class _ToothButton extends StatelessWidget {
const _ToothButton({
required this.tooth,
required this.selected,
required this.onTap,
});
final int tooth;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.all(1.5),
height: 36,
decoration: BoxDecoration(
color: selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Text(
'$tooth',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: selected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
),
),
);
}
}
class _FilePicker extends StatelessWidget {
const _FilePicker({
required this.files,
required this.onAdd,
required this.onRemove,
});
final List<PlatformFile> files;
final VoidCallback onAdd;
final void Function(int index) onRemove;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (files.isNotEmpty) ...[
...files.asMap().entries.map((e) {
final i = e.key;
final f = e.value;
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
const Icon(Icons.attach_file, size: 16, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: Text(
f.name,
style: const TextStyle(
fontSize: 13, color: AppColors.textPrimary),
overflow: TextOverflow.ellipsis,
),
),
Text(
_formatSize(f.size),
style: const TextStyle(
fontSize: 11, color: AppColors.textMuted),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => onRemove(i),
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
),
],
),
);
}),
const SizedBox(height: 4),
],
OutlinedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.upload_file_outlined, size: 18),
label: const Text('Dosya Seç'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.accent,
side: const BorderSide(color: AppColors.accent),
),
),
],
);
}
String _formatSize(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
class _PricePreviewChip extends StatelessWidget {
const _PricePreviewChip({required this.product, required this.effectivePrice});
final ProstheticProduct product;
final double effectivePrice;
@override
Widget build(BuildContext context) {
final currency = product.currency ?? 'TRY';
final unitPrice = product.unitPrice!;
final hasDiscount = (effectivePrice - unitPrice).abs() > 0.01;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.success.withValues(alpha: 0.25)),
),
child: Row(
children: [
const Icon(Icons.sell_outlined, size: 16, color: AppColors.success),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${product.name}${effectivePrice.toStringAsFixed(2)} $currency',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.success),
),
if (hasDiscount)
Text(
'Liste: ${unitPrice.toStringAsFixed(2)} $currency · İndirim uygulandı',
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
)
else
Text(
'Liste fiyatı · İndirim yok',
style: TextStyle(fontSize: 11, color: AppColors.success.withValues(alpha: 0.75)),
),
],
),
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
);
}
}
class _ProvaToggle extends StatelessWidget {
const _ProvaToggle({
required this.value,
required this.onChanged,
this.prostheticType,
});
final bool value;
final ValueChanged<bool> onChanged;
final ProstheticType? prostheticType;
@override
Widget build(BuildContext context) {
final steps = prostheticType != null
? jobStepTemplate(prostheticType!, value)
: <JobStep>[];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: value ? AppColors.inProgressBg : AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: value ? AppColors.inProgress.withValues(alpha: 0.3) : AppColors.border,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
value ? Icons.swap_horiz_rounded : Icons.straighten_rounded,
size: 20,
color: value ? AppColors.inProgress : AppColors.textSecondary,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value ? 'Provalı İş' : 'Provasız İş',
style: TextStyle(
fontWeight: FontWeight.w700,
color: value ? AppColors.inProgress : AppColors.textPrimary,
fontSize: 14,
),
),
Text(
value
? 'Lab her adımda klinik onayı bekler'
: 'Lab doğrudan üretip teslime gönderir',
style: const TextStyle(
fontSize: 12, color: AppColors.textSecondary),
),
],
),
),
Switch(
value: value,
onChanged: onChanged,
activeColor: AppColors.inProgress,
),
],
),
if (steps.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
children: steps.map((s) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.border),
),
child: Text(
s.label,
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
)).toList(),
),
],
],
),
);
}
}