Files
lab-app/lib/features/clinic/dashboard/clinic_dashboard_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

1231 lines
46 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 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/services/realtime_service.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/tooth_logo.dart';
import '../../../models/job.dart';
import '../jobs/clinic_jobs_repository.dart';
import '../patients/clinic_patients_repository.dart';
class ClinicDashboardScreen extends ConsumerStatefulWidget {
const ClinicDashboardScreen({super.key});
@override
ConsumerState<ClinicDashboardScreen> createState() =>
_ClinicDashboardScreenState();
}
class _ClinicDashboardScreenState extends ConsumerState<ClinicDashboardScreen> {
late Future<_DashboardData> _future;
late UnsubFn _unsub;
final Map<String, bool> _actingJobs = {};
@override
void initState() {
super.initState();
_load();
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
_unsub = RealtimeService.instance.watch(
'jobs',
filter: "clinic_tenant_id='$tenantId'",
onEvent: (_) { if (mounted) _load(); },
);
}
@override
void dispose() {
_unsub();
super.dispose();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = _loadAll(tenantId);
});
}
Future<void> _approveAtClinic(Job job) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(job.patientCode),
content: Text('${job.prostheticType.label} işini onaylıyor musunuz?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: AppColors.success),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Onayla'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _actingJobs[job.id] = true);
try {
await ClinicJobsRepository.instance.approveAtClinic(job.id, job);
_load();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
} finally {
if (mounted) setState(() => _actingJobs.remove(job.id));
}
}
Future<void> _markDelivered(Job job) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(job.patientCode),
content: Text('${job.prostheticType.label} işi teslim alındı olarak işaretlensin mi?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Teslim Aldım'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _actingJobs[job.id] = true);
try {
await ClinicJobsRepository.instance.markDelivered(job.id, job);
_load();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Hata: $e')));
} finally {
if (mounted) setState(() => _actingJobs.remove(job.id));
}
}
Future<_DashboardData> _loadAll(String tenantId) async {
final now = DateTime.now();
final thisMonthStart = DateTime(now.year, now.month, 1);
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
final results = await Future.wait([
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['pending'], limit: 200),
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['in_progress'], limit: 200),
ClinicJobsRepository.instance.listOutbound(tenantId, statuses: ['sent'], limit: 200),
ClinicJobsRepository.instance.listOutbound(tenantId, limit: 5),
ClinicPatientsRepository.instance.listPatients(tenantId, limit: 200),
]);
final thisMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart);
final lastMonth = await ClinicJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart);
final inProgressJobs = results[1] as List<Job>;
final sentJobs = results[2] as List<Job>;
final provaAtClinic = inProgressJobs.where((j) => j.location == JobLocation.atClinic).toList();
final actionJobs = [...provaAtClinic, ...sentJobs];
return _DashboardData(
pendingCount: (results[0] as List).length,
inProgressCount: inProgressJobs.length,
sentCount: sentJobs.length,
patientCount: (results[4] as List).length,
recentJobs: results[3] as List<Job>,
thisMonthDelivered: thisMonth,
lastMonthDelivered: lastMonth,
actionJobs: actionJobs,
);
}
@override
Widget build(BuildContext context) {
final companyName =
ref.watch(authProvider).activeTenant?.tenant.companyName ?? '';
return Scaffold(
backgroundColor: AppColors.background,
body: LayoutBuilder(
builder: (context, constraints) {
const maxContent = 1040.0;
final hPad = constraints.maxWidth > maxContent
? (constraints.maxWidth - maxContent) / 2
: 16.0;
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<_DashboardData>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return _DashboardSkeleton(companyName: companyName, hPad: hPad);
}
if (snap.hasError) {
return _ErrorBody(onRetry: _load);
}
final data = snap.data!;
final isDesktop = MediaQuery.sizeOf(ctx).width > AppLayout.sidebarBreakpoint;
return CustomScrollView(
slivers: [
_DashboardHeader(companyName: companyName),
if (isDesktop)
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
sliver: SliverToBoxAdapter(
child: _StatsRow(
pending: data.pendingCount,
inProgress: data.inProgressCount,
sent: data.sentCount,
patients: data.patientCount,
),
),
),
if (isDesktop) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
sliver: SliverToBoxAdapter(
child: _MonthlyReportSection(data: data)
.animate().fadeIn(duration: 300.ms).slideY(begin: 0.08, end: 0),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
sliver: SliverToBoxAdapter(
child: _GamificationRow(data: data)
.animate().fadeIn(duration: 300.ms, delay: 60.ms).slideY(begin: 0.08, end: 0),
),
),
],
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
sliver: SliverToBoxAdapter(
child: FilledButton.icon(
onPressed: () => context.push(routeClinicJobNew),
icon: const Icon(Icons.add_rounded, size: 20),
label: const Text('Yeni İş Oluştur'),
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
backgroundColor: AppColors.accent,
),
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
),
),
if (data.actionJobs.isNotEmpty)
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 20, hPad, 0),
sliver: SliverToBoxAdapter(
child: _ActionSection(
jobs: data.actionJobs,
actingJobs: _actingJobs,
onApprove: _approveAtClinic,
onDeliver: _markDelivered,
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.06, end: 0),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 20, hPad, 4),
sliver: SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Son İşler',
style: Theme.of(context).textTheme.titleMedium),
TextButton(
onPressed: () => context.go(routeClinicJobs),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
child: const Text('Tümünü Gör'),
),
],
),
),
),
if (data.recentJobs.isEmpty)
const SliverFillRemaining(
hasScrollBody: false, child: _EmptyJobs())
else
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 24),
sliver: SliverList.separated(
itemCount: data.recentJobs.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (ctx, i) =>
_JobCard(job: data.recentJobs[i])
.animate(delay: (i * 60).ms)
.fadeIn(duration: 300.ms)
.slideY(begin: 0.12, end: 0),
),
),
],
);
},
),
);
},
),
);
}
}
class _DashboardData {
const _DashboardData({
required this.pendingCount,
required this.inProgressCount,
required this.sentCount,
required this.patientCount,
required this.recentJobs,
required this.thisMonthDelivered,
required this.lastMonthDelivered,
required this.actionJobs,
});
final int pendingCount;
final int inProgressCount;
final int sentCount;
final int patientCount;
final List<Job> recentJobs;
final int thisMonthDelivered;
final int lastMonthDelivered;
final List<Job> actionJobs;
int get points => thisMonthDelivered * 10;
double get changePercent => lastMonthDelivered == 0
? (thisMonthDelivered > 0 ? 100 : 0)
: (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100;
}
// ── Action Section ───────────────────────────────────────────────────────────
class _ActionSection extends StatelessWidget {
const _ActionSection({
required this.jobs,
required this.actingJobs,
required this.onApprove,
required this.onDeliver,
});
final List<Job> jobs;
final Map<String, bool> actingJobs;
final Future<void> Function(Job) onApprove;
final Future<void> Function(Job) onDeliver;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 26, height: 26,
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(7)),
child: const Icon(Icons.priority_high_rounded, size: 15, color: Colors.white),
),
const SizedBox(width: 8),
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(10)),
child: Text('${jobs.length}', style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: Colors.white)),
),
],
),
const SizedBox(height: 12),
...jobs.asMap().entries.map((entry) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _ActionJobCard(
job: entry.value,
acting: actingJobs[entry.value.id] == true,
onApprove: () => onApprove(entry.value),
onDeliver: () => onDeliver(entry.value),
).animate(delay: (entry.key * 50).ms).fadeIn(duration: 250.ms).slideY(begin: 0.08, end: 0),
)),
],
);
}
}
class _ActionJobCard extends StatelessWidget {
const _ActionJobCard({
required this.job,
required this.acting,
required this.onApprove,
required this.onDeliver,
});
final Job job;
final bool acting;
final VoidCallback onApprove;
final VoidCallback onDeliver;
bool get _isProva => job.status == JobStatus.inProgress && job.location == JobLocation.atClinic;
@override
Widget build(BuildContext context) {
final isProva = _isProva;
final borderColor = isProva ? AppColors.pending : AppColors.accent;
final bgColor = isProva ? AppColors.pendingBg : AppColors.inProgressBg;
final iconColor = isProva ? AppColors.pending : AppColors.accent;
final icon = isProva ? Icons.rate_review_outlined : Icons.inventory_2_outlined;
final statusLabel = isProva ? 'Onay Bekliyor' : 'Teslimat Bekliyor';
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => context.push('/clinic/jobs/${job.id}'),
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: borderColor.withValues(alpha: 0.45), width: 1.5),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 3))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
child: Row(
children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(11)),
child: Icon(icon, color: iconColor, size: 19),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(job.patientCode, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: 2),
Text(
'${job.prostheticType.label} · ${job.labName ?? 'Lab'}',
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
maxLines: 1, overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(8)),
child: Text(statusLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, color: iconColor)),
),
],
),
),
Container(
decoration: BoxDecoration(
color: bgColor.withValues(alpha: 0.45),
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(13), bottomRight: Radius.circular(13)),
),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: acting
? const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.accent)),
),
)
: isProva
? Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: onApprove,
icon: const Icon(Icons.check_circle_outline, size: 15),
label: const Text('Onayla', style: TextStyle(fontSize: 13)),
style: FilledButton.styleFrom(
backgroundColor: AppColors.success,
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => context.push('/clinic/jobs/${job.id}'),
icon: const Icon(Icons.open_in_new_rounded, size: 14),
label: const Text('Detay', style: TextStyle(fontSize: 13)),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 12),
foregroundColor: AppColors.pending,
side: BorderSide(color: AppColors.pending.withValues(alpha: 0.6)),
),
),
])
: Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: onDeliver,
icon: const Icon(Icons.inventory_2_outlined, size: 15),
label: const Text('Teslim Aldım', style: TextStyle(fontSize: 13)),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => context.push('/clinic/jobs/${job.id}'),
icon: const Icon(Icons.open_in_new_rounded, size: 14),
label: const Text('Detay', style: TextStyle(fontSize: 13)),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 12),
foregroundColor: AppColors.accent,
side: BorderSide(color: AppColors.accent.withValues(alpha: 0.6)),
),
),
]),
),
],
),
),
),
),
);
}
}
// ── Monthly Report ──────────────────────────────────────────────────────────
class _MonthlyReportSection extends StatelessWidget {
const _MonthlyReportSection({required this.data});
final _DashboardData data;
@override
Widget build(BuildContext context) {
final pct = data.changePercent;
final isUp = pct >= 0;
final pctStr = '${isUp ? '+' : ''}${pct.toStringAsFixed(0)}%';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bar_chart_rounded, size: 18, color: AppColors.accent),
const SizedBox(width: 6),
Text('Aylık Rapor', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(child: _MonthStat(label: 'Bu Ay', value: data.thisMonthDelivered, highlighted: true)),
const SizedBox(width: 12),
Expanded(child: _MonthStat(label: 'Geçen Ay', value: data.lastMonthDelivered, highlighted: false)),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isUp ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isUp ? Icons.trending_up_rounded : Icons.trending_down_rounded,
size: 16,
color: isUp ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 4),
Text(
pctStr,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isUp ? AppColors.success : AppColors.cancelled,
),
),
],
),
),
],
),
],
),
);
}
}
class _MonthStat extends StatelessWidget {
const _MonthStat({required this.label, required this.value, required this.highlighted});
final String label;
final int value;
final bool highlighted;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: highlighted ? AppColors.accent.withValues(alpha: 0.06) : AppColors.background,
borderRadius: BorderRadius.circular(8),
border: highlighted ? Border.all(color: AppColors.accent.withValues(alpha: 0.2)) : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 11, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(
'$value',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: highlighted ? AppColors.accent : AppColors.textPrimary,
),
),
],
),
);
}
}
// ── Gamification Row ─────────────────────────────────────────────────────────
const _monthlyGoal = 20;
class _GamificationRow extends StatelessWidget {
const _GamificationRow({required this.data});
final _DashboardData data;
@override
Widget build(BuildContext context) {
final progress = (data.thisMonthDelivered / _monthlyGoal).clamp(0.0, 1.0);
final remaining = (_monthlyGoal - data.thisMonthDelivered).clamp(0, _monthlyGoal);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('🏆', style: TextStyle(fontSize: 16)),
const SizedBox(width: 6),
Text('Aylık Hedef', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${data.points} puan',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary),
),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: progress,
minHeight: 8,
backgroundColor: AppColors.background,
valueColor: AlwaysStoppedAnimation<Color>(
progress >= 1.0 ? AppColors.success : AppColors.accent,
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${data.thisMonthDelivered} / $_monthlyGoal iş teslim edildi',
style: TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
Text(
progress >= 1.0 ? 'Hedef tamamlandı!' : '$remaining iş kaldı',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: progress >= 1.0 ? AppColors.success : AppColors.textSecondary,
),
),
],
),
],
),
);
}
}
// ── Header ──────────────────────────────────────────────────────────────────
class _DashboardHeader extends StatelessWidget {
const _DashboardHeader({required this.companyName});
final String companyName;
static const double _desktopToolbarHeight = 64;
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
if (isDesktop) {
return SliverAppBar(
pinned: true,
toolbarHeight: _desktopToolbarHeight,
backgroundColor: AppColors.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Genel Bakış', style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.8), letterSpacing: 0.3)),
const Text('Bugünkü Durum', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
],
),
),
actions: [
IconButton(
onPressed: () => context.go(routeClinicSettings),
icon: const Icon(Icons.settings_outlined, color: AppColors.textSecondary, size: 22),
),
const SizedBox(width: 8),
],
);
}
return SliverAppBar(
pinned: true,
expandedHeight: 148,
backgroundColor: AppColors.primary,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.light,
centerTitle: false,
leadingWidth: 60,
leading: Padding(
padding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Center(child: ToothLogo(size: 20, color: Colors.white)),
),
),
titleSpacing: 8,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('DLS', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 1.5)),
Text(companyName, style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
actions: [
IconButton(
onPressed: () => context.go(routeClinicSettings),
icon: const Icon(Icons.settings_outlined, color: Colors.white, size: 22),
),
],
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.accent],
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('Genel Bakış', style: TextStyle(color: Colors.white.withValues(alpha: 0.65), fontSize: 12, fontWeight: FontWeight.w500, letterSpacing: 0.5)),
const SizedBox(height: 4),
const Text('Bugünkü Durum', style: TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
],
),
),
),
),
);
}
}
// ── Stats ────────────────────────────────────────────────────────────────────
class _StatsRow extends StatelessWidget {
const _StatsRow({
required this.pending,
required this.inProgress,
required this.sent,
required this.patients,
});
final int pending;
final int inProgress;
final int sent;
final int patients;
@override
Widget build(BuildContext context) {
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
final c1 = _StatCard(label: 'Bekleyen', value: '$pending', icon: Icons.hourglass_top_rounded, color: AppColors.pending, bgColor: AppColors.pendingBg)
.animate().fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
final c2 = _StatCard(label: 'Devam Eden', value: '$inProgress', icon: Icons.autorenew_rounded, color: AppColors.inProgress, bgColor: AppColors.inProgressBg)
.animate(delay: 80.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
final c3 = _StatCard(label: 'Toplam Hasta', value: '$patients', icon: Icons.people_outline_rounded, color: AppColors.success, bgColor: AppColors.successBg)
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
// Wide desktop (≥ 1100px): 4 cards side by side — full lifecycle view.
if (isWideDesktop) {
final c4 = _StatCard(label: 'Klinik\'te', value: '$sent', icon: Icons.local_hospital_outlined, color: AppColors.accent, bgColor: AppColors.inProgressBg)
.animate(delay: 120.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
return Row(
children: [
Expanded(child: c1),
const SizedBox(width: 12),
Expanded(child: c2),
const SizedBox(width: 12),
Expanded(child: c4),
const SizedBox(width: 12),
Expanded(child: c3),
],
);
}
// Mobile + narrow sidebar (< 1100px): 2+1 column layout.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(child: c1),
const SizedBox(width: 12),
Expanded(child: c2),
],
),
const SizedBox(height: 12),
c3,
],
);
}
}
class _StatCard extends StatelessWidget {
const _StatCard({
required this.label,
required this.value,
required this.icon,
required this.color,
required this.bgColor,
});
final String label;
final String value;
final IconData icon;
final Color color;
final Color bgColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4))
],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration:
BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: color,
height: 1)),
const SizedBox(height: 3),
Text(label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500)),
],
),
],
),
);
}
}
// ── Job Card ─────────────────────────────────────────────────────────────────
class _JobCard extends StatelessWidget {
const _JobCard({required this.job});
final Job job;
@override
Widget build(BuildContext context) {
final due = job.dueDate;
final isOverdue = due != null && due.isBefore(DateTime.now());
final dueText = due != null
? '${due.day.toString().padLeft(2, '0')}.${due.month.toString().padLeft(2, '0')}.${due.year}'
: null;
final statusColor = _statusColor(job.status);
final statusBg = _statusBg(job.status);
return Semantics(
label: job.patientCode,
button: true,
excludeSemantics: true,
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: () => context.push('/clinic/jobs/${job.id}'),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border)),
child: Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: statusBg, borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.work_outline_rounded,
color: statusColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(job.patientCode,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
const SizedBox(height: 2),
Text(job.labName ?? 'Laboratuvar',
style: const TextStyle(
fontSize: 13, color: AppColors.textSecondary)),
const SizedBox(height: 6),
Wrap(
spacing: 6,
children: [
_Tag(
label: job.prostheticType.label,
color: AppColors.inProgress,
bg: AppColors.inProgressBg),
_Tag(
label: job.status.label,
color: statusColor,
bg: statusBg),
],
),
],
),
),
if (dueText != null) ...[
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(Icons.calendar_today_outlined,
size: 13,
color: isOverdue
? AppColors.cancelled
: AppColors.textMuted),
const SizedBox(height: 3),
Text(
dueText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isOverdue
? AppColors.cancelled
: AppColors.textSecondary),
),
],
),
],
],
),
),
),
),
);
}
Color _statusColor(JobStatus s) {
switch (s) {
case JobStatus.pending:
return AppColors.pending;
case JobStatus.inProgress:
return AppColors.inProgress;
case JobStatus.sent:
return AppColors.accent;
case JobStatus.delivered:
return AppColors.success;
case JobStatus.cancelled:
return AppColors.cancelled;
}
}
Color _statusBg(JobStatus s) {
switch (s) {
case JobStatus.pending:
return AppColors.pendingBg;
case JobStatus.inProgress:
return AppColors.inProgressBg;
case JobStatus.sent:
return AppColors.inProgressBg;
case JobStatus.delivered:
return AppColors.successBg;
case JobStatus.cancelled:
return AppColors.cancelledBg;
}
}
}
class _Tag extends StatelessWidget {
const _Tag({required this.label, required this.color, required this.bg});
final String label;
final Color color;
final Color bg;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration:
BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label,
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}
// ── Empty / Error / Skeleton ─────────────────────────────────────────────────
class _EmptyJobs extends StatelessWidget {
const _EmptyJobs();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20)),
child: const Icon(Icons.work_off_outlined,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
const Text('Henüz iş yok',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
const SizedBox(height: 6),
const Text(
'Yeni iş oluşturduğunuzda\nburada görünecek',
textAlign: TextAlign.center,
style:
TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
],
),
);
}
}
class _ErrorBody extends StatelessWidget {
const _ErrorBody({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
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),
const Text('Bağlantı hatası',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene')),
],
),
),
);
}
}
class _DashboardSkeleton extends StatelessWidget {
const _DashboardSkeleton({required this.companyName, required this.hPad});
final String companyName;
final double hPad;
@override
Widget build(BuildContext context) {
return CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [
_DashboardHeader(companyName: companyName),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 16, hPad, 0),
sliver: const SliverToBoxAdapter(
child: Column(
children: [
Row(children: [
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
SizedBox(width: 12),
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
]),
SizedBox(height: 12),
_ShimmerBox(height: 84, radius: 16),
],
),
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(hPad, 8, hPad, 0),
sliver: SliverList.builder(
itemCount: 4,
itemBuilder: (_, i) => const Padding(
padding: EdgeInsets.only(bottom: 10),
child: _ShimmerBox(height: 92, radius: 14)),
),
),
],
);
}
}
class _ShimmerBox extends StatefulWidget {
const _ShimmerBox({required this.height, required this.radius});
final double height;
final double radius;
@override
State<_ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<_ShimmerBox>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1100))
..repeat(reverse: true);
_anim = CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (_, __) => Container(
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.radius),
color: Color.lerp(const Color(0xFFE2E8F0),
const Color(0xFFF1F5F9), _anim.value)),
),
);
}
}