Initial commit — DLS lab-app Flutter project

This commit is contained in:
egecankomur
2026-06-10 23:22:15 +03:00
commit d1acc1d367
225 changed files with 31294 additions and 0 deletions
@@ -0,0 +1,534 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/auth_provider.dart';
import '../../../core/providers/locale_provider.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/currency_formatter.dart';
import '../../../core/widgets/gradient_app_bar.dart';
import '../../../core/widgets/pill_tabs.dart';
import '../../../models/finance_entry.dart';
import 'clinic_finance_repository.dart';
enum _FinanceSort { newestFirst, byAmountDesc, byAmountAsc }
class ClinicFinanceScreen extends ConsumerStatefulWidget {
const ClinicFinanceScreen({super.key});
@override
ConsumerState<ClinicFinanceScreen> createState() =>
_ClinicFinanceScreenState();
}
class _ClinicFinanceScreenState extends ConsumerState<ClinicFinanceScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<Map<String, double>> _summaryFuture;
_FinanceSort _sort = _FinanceSort.newestFirst;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
if (mounted) setState(() {});
});
_loadSummary();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _loadSummary() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_summaryFuture =
ClinicFinanceRepository.instance.summary(tenantId);
});
}
Future<void> _showSortOptions() async {
final s = ref.read(stringsProvider);
final result = await showSortSheet(
context,
title: s.sort,
options: [s.sortNewest, s.sortAmountDesc, s.sortAmountAsc],
current: _sort.index,
);
if (result != null) {
setState(() => _sort = _FinanceSort.values[result]);
}
}
@override
Widget build(BuildContext context) {
final isSortActive = _sort != _FinanceSort.newestFirst;
final s = ref.watch(stringsProvider);
final currencyCode =
ref.watch(authProvider).activeTenant?.tenant.defaultCurrency ?? 'TRY';
return Scaffold(
backgroundColor: AppColors.background,
appBar: GradientAppBar(
title: s.finance,
category: s.clinicCategory,
actions: [
IconButton(
onPressed: _showSortOptions,
tooltip: 'Sırala',
icon: Badge(
isLabelVisible: isSortActive,
smallSize: 8,
backgroundColor: AppColors.accent,
child: const Icon(Icons.sort_rounded),
),
),
],
),
body: Column(
children: [
FutureBuilder<Map<String, double>>(
future: _summaryFuture,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const LinearProgressIndicator(
color: AppColors.accent);
}
final summary = snap.data ?? {'pending': 0.0, 'paid': 0.0};
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
label: s.pendingReceivable,
amount: summary['pending'] ?? 0.0,
currencyCode: currencyCode,
color: AppColors.pending,
bgColor: AppColors.pendingBg,
icon: Icons.hourglass_empty_rounded,
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
label: s.collected,
amount: summary['paid'] ?? 0.0,
currencyCode: currencyCode,
color: AppColors.success,
bgColor: AppColors.successBg,
icon: Icons.check_circle_outline,
),
),
],
),
);
},
),
PillTabs(
tabs: [s.pending, s.collected],
selected: _tabController.index,
onSelect: (i) => _tabController.animateTo(i),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_FinanceTab(
status: 'pending',
sort: _sort,
onPaymentMade: _loadSummary,
currencyCode: currencyCode,
),
_FinanceTab(
status: 'paid',
sort: _sort,
onPaymentMade: _loadSummary,
currencyCode: currencyCode,
),
],
),
),
],
),
);
}
}
class _SummaryCard extends StatelessWidget {
const _SummaryCard({
required this.label,
required this.amount,
required this.currencyCode,
required this.color,
required this.bgColor,
required this.icon,
});
final String label;
final double amount;
final String currencyCode;
final Color color;
final Color bgColor;
final IconData icon;
@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),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
CurrencyFormatter.format(amount, currencyCode),
style: TextStyle(
fontSize: 20,
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 _FinanceTab extends ConsumerStatefulWidget {
const _FinanceTab({
required this.status,
required this.sort,
required this.onPaymentMade,
required this.currencyCode,
});
final String status;
final _FinanceSort sort;
final VoidCallback onPaymentMade;
final String currencyCode;
@override
ConsumerState<_FinanceTab> createState() => _FinanceTabState();
}
class _FinanceTabState extends ConsumerState<_FinanceTab> {
late Future<List<FinanceEntry>> _future;
@override
void initState() {
super.initState();
_load();
}
void _load() {
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
setState(() {
_future = ClinicFinanceRepository.instance.listEntries(
tenantId,
status: widget.status,
limit: 100,
);
});
}
List<FinanceEntry> _sorted(List<FinanceEntry> entries) {
final list = List<FinanceEntry>.from(entries);
switch (widget.sort) {
case _FinanceSort.newestFirst:
list.sort((a, b) {
final da = a.dateCreated != null
? DateTime.tryParse(a.dateCreated!)
: null;
final db = b.dateCreated != null
? DateTime.tryParse(b.dateCreated!)
: null;
if (da == null && db == null) return 0;
if (da == null) return 1;
if (db == null) return -1;
return db.compareTo(da);
});
case _FinanceSort.byAmountDesc:
list.sort((a, b) => b.amount.compareTo(a.amount));
case _FinanceSort.byAmountAsc:
list.sort((a, b) => a.amount.compareTo(b.amount));
}
return list;
}
Future<void> _markPaid(FinanceEntry entry) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Ödeme Onayı'),
content: Text(
'${entry.counterpartyName ?? "Bu kayıt"} için '
'${CurrencyFormatter.format(entry.amount, widget.currencyCode)} tutarındaki borcu '
'ödendi olarak işaretlemek istiyor musunuz?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('İptal'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Ödendi'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await ClinicFinanceRepository.instance.markPaid(entry.id);
_load();
widget.onPaymentMade();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ödeme kaydedildi.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Hata: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
color: AppColors.accent,
onRefresh: () async => _load(),
child: FutureBuilder<List<FinanceEntry>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accent));
}
if (snap.hasError) {
return Center(
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),
Text('Hata: ${snap.error}',
style: const TextStyle(
color: AppColors.textSecondary)),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Tekrar Dene'),
),
],
),
);
}
final entries = _sorted(snap.data!);
if (entries.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(20)),
child: const Icon(Icons.receipt_long_outlined,
color: AppColors.inProgress, size: 32),
),
const SizedBox(height: 16),
const Text(
'Kayıt bulunamadı',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final isPending = entry.status == FinanceStatus.pending;
final statusColor =
isPending ? AppColors.pending : AppColors.success;
final statusBg =
isPending ? AppColors.pendingBg : AppColors.successBg;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: isPending ? () => _markPaid(entry) : null,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2))
]),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: statusBg,
borderRadius: BorderRadius.circular(12)),
child: Icon(
isPending
? Icons.hourglass_empty_rounded
: Icons.check_circle_outline,
color: statusColor,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
entry.counterpartyName ??
'Bilinmiyor',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary),
),
),
Text(
CurrencyFormatter.format(
entry.amount, widget.currencyCode),
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: statusColor),
),
],
),
if (entry.patientCode != null) ...[
const SizedBox(height: 2),
Text(
'Protokol: ${entry.patientCode}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary),
),
],
if (entry.dateCreated != null) ...[
const SizedBox(height: 2),
Text(
_formatDate(entry.dateCreated!),
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted),
),
],
],
),
),
if (isPending) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.pending,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Öde',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
],
),
),
),
),
);
},
);
},
),
);
}
String _formatDate(String dateStr) {
try {
final d = DateTime.parse(dateStr);
return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
} catch (_) {
return dateStr;
}
}
}