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
This commit is contained in:
@@ -0,0 +1,883 @@
|
||||
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/theme/app_theme.dart';
|
||||
import '../../../core/widgets/tooth_logo.dart';
|
||||
import '../../../core/services/realtime_service.dart';
|
||||
import '../../../models/job.dart';
|
||||
import '../jobs/lab_jobs_repository.dart';
|
||||
|
||||
class LabDashboardScreen extends ConsumerStatefulWidget {
|
||||
const LabDashboardScreen({super.key});
|
||||
@override
|
||||
ConsumerState<LabDashboardScreen> createState() => _LabDashboardScreenState();
|
||||
}
|
||||
|
||||
class _LabDashboardScreenState extends ConsumerState<LabDashboardScreen> {
|
||||
late Future<_DashboardData> _future;
|
||||
bool _acceptingAll = false;
|
||||
late UnsubFn _unsub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
_unsub = RealtimeService.instance.watch(
|
||||
'jobs',
|
||||
filter: "lab_tenant_id='$tenantId'",
|
||||
onEvent: (_) { if (mounted) _load(); },
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_unsub();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final now = DateTime.now();
|
||||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||||
final lastMonthStart = DateTime(now.year, now.month - 1, 1);
|
||||
setState(() {
|
||||
_future = Future.wait([
|
||||
Future.wait<List<Job>>([
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'pending'),
|
||||
LabJobsRepository.instance.listInProgress(tenantId),
|
||||
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_lab'),
|
||||
LabJobsRepository.instance.listInProgress(tenantId, location: 'at_clinic'),
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'sent', limit: 200),
|
||||
LabJobsRepository.instance.listInbound(tenantId, status: 'delivered', limit: 200),
|
||||
]),
|
||||
LabJobsRepository.instance.countDelivered(tenantId, from: thisMonthStart),
|
||||
LabJobsRepository.instance.countDelivered(tenantId, from: lastMonthStart, to: thisMonthStart),
|
||||
]).then((r) {
|
||||
final jobs = r[0] as List<List<Job>>;
|
||||
return _DashboardData(
|
||||
pendingJobs: jobs[0],
|
||||
inProgressJobs: jobs[1],
|
||||
atLabJobs: jobs[2],
|
||||
atClinicJobs: jobs[3],
|
||||
sentCount: jobs[4].length,
|
||||
deliveredCount: jobs[5].length,
|
||||
thisMonthDelivered: r[1] as int,
|
||||
lastMonthDelivered: r[2] as int,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _bulkAccept() async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
setState(() => _acceptingAll = true);
|
||||
try {
|
||||
await LabJobsRepository.instance.bulkAcceptPending(tenantId);
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Hata: $e'), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _acceptingAll = false);
|
||||
}
|
||||
}
|
||||
|
||||
@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.pendingJobs.length,
|
||||
inProgress: data.inProgressJobs.length,
|
||||
sent: data.sentCount,
|
||||
delivered: data.deliveredCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (data.pendingJobs.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _AcceptAllBanner(
|
||||
count: data.pendingJobs.length,
|
||||
loading: _acceptingAll,
|
||||
onTap: _bulkAccept,
|
||||
).animate().fadeIn(duration: 300.ms).slideY(begin: 0.1, end: 0),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
// ── Yapılacaklar (at_lab) ────────────────────────────
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Yapılacaklar', style: Theme.of(context).textTheme.titleMedium),
|
||||
TextButton(
|
||||
onPressed: () => context.go(routeLabJobsAll),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.accent, padding: const EdgeInsets.symmetric(horizontal: 8)),
|
||||
child: const Text('Tümünü Gör'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (data.atLabJobs.isEmpty)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: _EmptySection(message: 'Yapılacak iş yok'),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: data.atLabJobs.take(5).length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) => _JobCard(job: data.atLabJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
),
|
||||
),
|
||||
// ── Klinikte Onay Bekliyor (at_clinic) ───────────────
|
||||
if (data.atClinicJobs.isNotEmpty) ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text('Klinikte Onay Bekliyor', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
sliver: SliverList.separated(
|
||||
itemCount: data.atClinicJobs.take(5).length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) => _JobCard(job: data.atClinicJobs[i])
|
||||
.animate(delay: (i * 60).ms)
|
||||
.fadeIn(duration: 300.ms)
|
||||
.slideY(begin: 0.12, end: 0),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DashboardHeader extends StatelessWidget {
|
||||
const _DashboardHeader({required this.companyName});
|
||||
final String companyName;
|
||||
|
||||
// Must stay in sync with _DesktopSidebar.headerHeight in app_router.dart
|
||||
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(routeLabSettings),
|
||||
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(routeLabSettings),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsRow extends StatelessWidget {
|
||||
const _StatsRow({
|
||||
required this.pending,
|
||||
required this.inProgress,
|
||||
required this.sent,
|
||||
required this.delivered,
|
||||
});
|
||||
final int pending;
|
||||
final int inProgress;
|
||||
final int sent;
|
||||
final int delivered;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWideDesktop = MediaQuery.sizeOf(context).width >= AppLayout.wideBreakpoint;
|
||||
|
||||
final pendingCard = _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 inProgressCard = _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);
|
||||
|
||||
if (isWideDesktop) {
|
||||
final sentCard = _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);
|
||||
final deliveredCard = _StatCard(label: 'Tamamlanan', value: '$delivered', icon: Icons.task_alt, color: AppColors.success, bgColor: AppColors.successBg)
|
||||
.animate(delay: 160.ms).fadeIn(duration: 350.ms).slideY(begin: 0.2, end: 0);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: pendingCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: inProgressCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: sentCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: deliveredCard),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: pendingCard),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: inProgressCard),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AcceptAllBanner extends StatelessWidget {
|
||||
const _AcceptAllBanner({required this.count, required this.loading, required this.onTap});
|
||||
final int count;
|
||||
final bool loading;
|
||||
final VoidCallback onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.pendingBg,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: loading ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.pending.withValues(alpha: 0.35))),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38, height: 38,
|
||||
decoration: BoxDecoration(color: AppColors.pending.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10)),
|
||||
child: const Icon(Icons.notifications_active_outlined, color: AppColors.pending, size: 18),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$count yeni iş bekliyor', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const Text('Tümünü hızlıca kabul et', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.pending))
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(color: AppColors.pending, borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('Kabul Et', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
return Semantics(
|
||||
label: job.patientCode,
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
child: Material(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/lab/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: AppColors.inProgressBg, borderRadius: BorderRadius.circular(12)),
|
||||
child: Center(child: Text('${job.memberCount}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.inProgress))),
|
||||
),
|
||||
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.clinicName ?? 'Klinik', 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),
|
||||
if (job.currentStep != null) _Tag(label: job.currentStep!.label, color: AppColors.success, bg: AppColors.successBg),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _EmptySection extends StatelessWidget {
|
||||
const _EmptySection({required this.message});
|
||||
final String message;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline_rounded, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(message, style: TextStyle(fontSize: 14, 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: Row(children: [
|
||||
Expanded(child: _ShimmerBox(height: 84, radius: 16)),
|
||||
SizedBox(width: 12),
|
||||
Expanded(child: _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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 iş',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: highlighted ? AppColors.accent : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gamification Row ─────────────────────────────────────────────────────────
|
||||
|
||||
const _monthlyGoal = 50;
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data Model ───────────────────────────────────────────────────────────────
|
||||
|
||||
class _DashboardData {
|
||||
final List<Job> pendingJobs;
|
||||
final List<Job> inProgressJobs;
|
||||
final List<Job> atLabJobs;
|
||||
final List<Job> atClinicJobs;
|
||||
final int sentCount;
|
||||
final int deliveredCount;
|
||||
final int thisMonthDelivered;
|
||||
final int lastMonthDelivered;
|
||||
const _DashboardData({
|
||||
required this.pendingJobs,
|
||||
required this.inProgressJobs,
|
||||
required this.atLabJobs,
|
||||
required this.atClinicJobs,
|
||||
required this.sentCount,
|
||||
required this.deliveredCount,
|
||||
required this.thisMonthDelivered,
|
||||
required this.lastMonthDelivered,
|
||||
});
|
||||
|
||||
int get points => thisMonthDelivered * 10;
|
||||
double get changePercent => lastMonthDelivered == 0
|
||||
? (thisMonthDelivered > 0 ? 100 : 0)
|
||||
: (thisMonthDelivered - lastMonthDelivered) / lastMonthDelivered * 100;
|
||||
}
|
||||
Reference in New Issue
Block a user