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:
Emre Emir
2026-06-11 15:57:31 +03:00
commit 8bbc9dbff2
226 changed files with 31308 additions and 0 deletions
+930
View File
@@ -0,0 +1,930 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/services/ai_actions.dart';
import '../../core/services/ai_context_builder.dart';
import '../../core/services/ai_service.dart';
import '../../core/theme/app_theme.dart';
import '../../core/utils/file_download_helper.dart';
import '../../models/job_file.dart';
import '../../models/tenant.dart';
class AiChatScreen extends ConsumerStatefulWidget {
const AiChatScreen({super.key});
@override
ConsumerState<AiChatScreen> createState() => _AiChatScreenState();
}
class _AiChatScreenState extends ConsumerState<AiChatScreen> {
final _inputController = TextEditingController();
final _scrollController = ScrollController();
final List<_Message> _messages = [];
String? _systemPrompt;
bool _loadingContext = true;
bool _streaming = false;
@override
void initState() {
super.initState();
_loadContext();
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _loadContext() async {
try {
final membership = ref.read(authProvider).activeTenant!;
final prompt = await AiContextBuilder.instance.build(membership);
if (mounted) setState(() { _systemPrompt = prompt; _loadingContext = false; });
} catch (_) {
if (mounted) setState(() => _loadingContext = false);
}
}
Future<void> _send([String? override]) async {
final text = (override ?? _inputController.text).trim();
if (text.isEmpty || _streaming || _systemPrompt == null) return;
_inputController.clear();
FocusScope.of(context).unfocus();
final apiMessages = [
..._messages.map((m) => {'role': m.isUser ? 'user' : 'assistant', 'content': m.rawText}),
{'role': 'user', 'content': text},
];
setState(() {
_messages.add(_Message.user(text));
_messages.add(_Message.assistantStreaming());
_streaming = true;
});
_scrollToBottom();
var accumulated = '';
try {
final stream = AiService.instance.streamChat(
systemPrompt: _systemPrompt!,
messages: apiMessages,
);
await for (final chunk in stream) {
if (!mounted) break;
accumulated += chunk;
setState(() => _messages[_messages.length - 1] = _Message.assistantStreaming(accumulated));
_scrollToBottom();
}
if (mounted) {
setState(() => _messages[_messages.length - 1] = _Message.assistantDone(accumulated));
}
} catch (e) {
if (mounted) {
setState(() => _messages[_messages.length - 1] = _Message.error('Bir hata olustu, tekrar deneyin.'));
}
} finally {
if (mounted) setState(() => _streaming = false);
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.auto_awesome, size: 16, color: Colors.white),
),
const SizedBox(width: 10),
const Text('AI Asistan'),
],
),
actions: [
if (_messages.isNotEmpty)
IconButton(
onPressed: () => setState(() => _messages.clear()),
icon: const Icon(Icons.refresh_outlined, size: 20),
tooltip: 'Sohbeti temizle',
),
],
),
body: Column(
children: [
Expanded(
child: _loadingContext
? const _LoadingContext()
: _messages.isEmpty
? _WelcomeView(onSuggestion: _send)
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
itemCount: _messages.length,
itemBuilder: (_, i) => _MessageBubble(
message: _messages[i],
membership: ref.read(authProvider).activeTenant!,
),
),
),
_InputBar(
controller: _inputController,
enabled: !_loadingContext && !_streaming,
streaming: _streaming,
onSend: _send,
),
],
),
);
}
}
// ── Message model ─────────────────────────────────────────────────────────────
enum _MsgKind { user, streaming, done, error }
class _Message {
_Message._({required this.kind, required this.rawText});
factory _Message.user(String text) =>
_Message._(kind: _MsgKind.user, rawText: text);
factory _Message.assistantStreaming([String text = '']) =>
_Message._(kind: _MsgKind.streaming, rawText: text);
factory _Message.assistantDone(String text) =>
_Message._(kind: _MsgKind.done, rawText: text);
factory _Message.error(String text) =>
_Message._(kind: _MsgKind.error, rawText: text);
final _MsgKind kind;
final String rawText;
bool get isUser => kind == _MsgKind.user;
bool get isStreaming => kind == _MsgKind.streaming;
bool get isError => kind == _MsgKind.error;
}
// ── Welcome view ──────────────────────────────────────────────────────────────
class _WelcomeView extends StatelessWidget {
const _WelcomeView({required this.onSuggestion});
final void Function(String) onSuggestion;
static const _suggestions = [
'Bekleyen islerimin ozeti nedir?',
'Gecikmiş iş var mı?',
'Bu ay kac is tamamlandi?',
'Revizyon oranım ne durumda?',
'Finans durumumu ozetle.',
'Son yuklenen dosyalari goster.',
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 32, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.auto_awesome, size: 28, color: Colors.white),
),
const SizedBox(height: 16),
const Text(
'Merhaba! Size nasil yardimci olabilirim?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
),
const SizedBox(height: 6),
const Text(
'Isler, finans ve ekip hakkinda soru sorabilir, islem yapabilirsiniz.',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 28),
const Text(
'ONERILER',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.textMuted, letterSpacing: 0.8),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: _suggestions
.map((s) => _SuggestionChip(label: s, onTap: () => onSuggestion(s)))
.toList(),
),
],
),
);
}
}
class _SuggestionChip extends StatelessWidget {
const _SuggestionChip({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
),
child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
),
);
}
}
// ── Message bubble ────────────────────────────────────────────────────────────
class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message, required this.membership});
final _Message message;
final TenantMembership membership;
@override
Widget build(BuildContext context) {
if (message.isUser) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.accent,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(4),
),
),
child: Text(
message.rawText,
style: const TextStyle(fontSize: 14, height: 1.5, color: Colors.white),
),
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
margin: const EdgeInsets.only(right: 8, top: 2),
decoration: BoxDecoration(color: AppColors.accent, borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.auto_awesome, size: 14, color: Colors.white),
),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
border: Border.all(color: AppColors.border),
),
child: message.isStreaming && message.rawText.isEmpty
? const _TypingDots()
: _MessageContent(message: message, membership: membership),
),
),
],
),
);
}
}
// ── Message content: markdown + action buttons ────────────────────────────────
class _MessageContent extends StatelessWidget {
const _MessageContent({required this.message, required this.membership});
final _Message message;
final TenantMembership membership;
@override
Widget build(BuildContext context) {
if (message.isError) {
return Text(
message.rawText,
style: const TextStyle(fontSize: 14, height: 1.5, color: AppColors.cancelled),
);
}
// During streaming: show raw text without parsing actions
if (message.isStreaming) {
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
}
// Done: parse segments → text + action buttons
final segments = parseSegments(message.rawText);
if (segments.isEmpty) {
return _MarkdownText(message.rawText, color: AppColors.textPrimary);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: segments.map((seg) {
if (seg is TextSegment) {
return _MarkdownText(seg.text, color: AppColors.textPrimary);
}
if (seg is ActionSegment) {
return Padding(
padding: const EdgeInsets.only(top: 10),
child: _ActionCard(action: seg.action, membership: membership),
);
}
return const SizedBox.shrink();
}).toList(),
);
}
}
// ── Action card ───────────────────────────────────────────────────────────────
enum _ActionState { idle, confirming, loading, success, error, files }
class _ActionCard extends StatefulWidget {
const _ActionCard({required this.action, required this.membership});
final AiAction action;
final TenantMembership membership;
@override
State<_ActionCard> createState() => _ActionCardState();
}
class _ActionCardState extends State<_ActionCard> {
_ActionState _state = _ActionState.idle;
String _resultMsg = '';
List<JobFile> _files = [];
AiAction get action => widget.action;
Future<void> _execute() async {
setState(() => _state = _ActionState.loading);
final outcome = await AiActionExecutor.execute(action, widget.membership);
if (!mounted) return;
switch (outcome) {
case ActionSuccess(:final message):
setState(() { _state = _ActionState.success; _resultMsg = message; });
case ActionError(:final error):
setState(() { _state = _ActionState.error; _resultMsg = error; });
case ActionFiles(:final files):
setState(() { _state = _ActionState.files; _files = files; });
}
}
void _onTap() {
if (action.isDangerous) {
setState(() => _state = _ActionState.confirming);
} else {
_execute();
}
}
@override
Widget build(BuildContext context) {
return switch (_state) {
_ActionState.idle => _idleButton(),
_ActionState.confirming => _confirmCard(),
_ActionState.loading => _loadingCard(),
_ActionState.success => _resultCard(success: true),
_ActionState.error => _resultCard(success: false),
_ActionState.files => _filesCard(),
};
}
Widget _idleButton() {
final isDanger = action.isDangerous;
final isFile = action.isFileAction;
final color = isDanger
? AppColors.cancelled
: isFile
? AppColors.inProgress
: AppColors.accent;
final bgColor = isDanger
? AppColors.cancelledBg
: isFile
? AppColors.inProgressBg
: AppColors.accent.withValues(alpha: 0.1);
final icon = isDanger
? Icons.cancel_outlined
: isFile
? Icons.folder_outlined
: action.type == 'add_member'
? Icons.person_add_outlined
: Icons.check_circle_outline;
return GestureDetector(
onTap: _onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Flexible(
child: Text(
action.label,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color),
),
),
],
),
),
);
}
Widget _confirmCard() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.cancelledBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.cancelled.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: AppColors.cancelled),
const SizedBox(width: 6),
const Expanded(
child: Text(
'Bu islem geri alinamayabilir.',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.cancelled),
),
),
],
),
const SizedBox(height: 4),
Text(action.label, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _state = _ActionState.idle),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 8),
side: BorderSide(color: AppColors.border),
),
child: const Text('Vazgec', style: TextStyle(fontSize: 13)),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: _execute,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cancelled,
padding: const EdgeInsets.symmetric(vertical: 8),
),
child: const Text('Onayla', style: TextStyle(fontSize: 13)),
),
),
],
),
],
),
);
}
Widget _loadingCard() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.background,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
),
const SizedBox(width: 8),
Text('Isleniyor...', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
],
),
);
}
Widget _resultCard({required bool success}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: success ? AppColors.successBg : AppColors.cancelledBg,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
success ? Icons.check_circle_outline : Icons.error_outline,
size: 16,
color: success ? AppColors.success : AppColors.cancelled,
),
const SizedBox(width: 8),
Flexible(
child: Text(
_resultMsg,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: success ? AppColors.success : AppColors.cancelled,
),
),
),
],
),
);
}
Widget _filesCard() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.inProgressBg,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.inProgress.withValues(alpha: 0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.folder_open_outlined, size: 15, color: AppColors.inProgress),
const SizedBox(width: 6),
Text(
'${_files.length} dosya',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.inProgress),
),
],
),
const SizedBox(height: 8),
..._files.map((f) => _FileDownloadRow(file: f)),
],
),
);
}
}
// ── File download row ─────────────────────────────────────────────────────────
class _FileDownloadRow extends StatefulWidget {
const _FileDownloadRow({required this.file});
final JobFile file;
@override
State<_FileDownloadRow> createState() => _FileDownloadRowState();
}
class _FileDownloadRowState extends State<_FileDownloadRow> {
bool _downloading = false;
IconData get _icon => switch (widget.file.kind) {
JobFileKind.scan => Icons.view_in_ar_rounded,
JobFileKind.image => Icons.image_outlined,
JobFileKind.document => Icons.description_outlined,
};
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Icon(_icon, size: 14, color: AppColors.inProgress),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.file.name,
style: const TextStyle(fontSize: 12, color: AppColors.textPrimary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 4),
Text(
widget.file.sizeLabel,
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
),
const SizedBox(width: 6),
_downloading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5, color: AppColors.inProgress),
)
: GestureDetector(
onTap: () async {
setState(() => _downloading = true);
await FileDownloadHelper.download(context, widget.file);
if (mounted) setState(() => _downloading = false);
},
child: const Icon(Icons.download_outlined, size: 18, color: AppColors.inProgress),
),
],
),
);
}
}
// ── Simple markdown renderer ──────────────────────────────────────────────────
class _MarkdownText extends StatelessWidget {
const _MarkdownText(this.text, {required this.color});
final String text;
final Color color;
@override
Widget build(BuildContext context) {
if (text.isEmpty) return const SizedBox.shrink();
final lines = text.split('\n');
final widgets = <Widget>[];
bool prevEmpty = false;
for (final raw in lines) {
final line = raw.trimRight();
if (line.isEmpty) {
if (!prevEmpty) widgets.add(const SizedBox(height: 6));
prevEmpty = true;
continue;
}
prevEmpty = false;
// Header ## or ###
if (line.startsWith('## ') || line.startsWith('### ')) {
final content = line.replaceFirst(RegExp(r'^#{2,3}\s+'), '');
widgets.add(Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Text(
content,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: color),
),
));
continue;
}
// Bullet
if (line.startsWith('- ') || line.startsWith('')) {
final content = line.substring(2);
widgets.add(Padding(
padding: const EdgeInsets.only(left: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('', style: TextStyle(color: color, height: 1.5, fontSize: 14)),
Expanded(child: _inlineText(content, color)),
],
),
));
continue;
}
widgets.add(_inlineText(line, color));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: widgets,
);
}
Widget _inlineText(String text, Color baseColor) {
final spans = <InlineSpan>[];
final boldPattern = RegExp(r'\*\*(.+?)\*\*');
int last = 0;
for (final m in boldPattern.allMatches(text)) {
if (m.start > last) {
spans.add(TextSpan(text: text.substring(last, m.start)));
}
spans.add(TextSpan(
text: m.group(1),
style: const TextStyle(fontWeight: FontWeight.w700),
));
last = m.end;
}
if (last < text.length) spans.add(TextSpan(text: text.substring(last)));
return RichText(
text: TextSpan(
style: TextStyle(fontSize: 14, height: 1.5, color: baseColor),
children: spans,
),
);
}
}
// ── Typing dots ───────────────────────────────────────────────────────────────
class _TypingDots extends StatefulWidget {
const _TypingDots();
@override
State<_TypingDots> createState() => _TypingDotsState();
}
class _TypingDotsState extends State<_TypingDots> with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 900))..repeat();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return SizedBox(
height: 18,
child: AnimatedBuilder(
animation: _ctrl,
builder: (_, __) => Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
final t = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
final opacity = (t < 0.5 ? t * 2 : (1 - t) * 2).clamp(0.3, 1.0);
return Container(
width: 6, height: 6,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: opacity),
shape: BoxShape.circle,
),
);
}),
),
),
);
}
}
// ── Input bar ─────────────────────────────────────────────────────────────────
class _InputBar extends StatelessWidget {
const _InputBar({
required this.controller,
required this.enabled,
required this.streaming,
required this.onSend,
});
final TextEditingController controller;
final bool enabled;
final bool streaming;
final VoidCallback onSend;
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.paddingOf(context).bottom;
return Container(
padding: EdgeInsets.fromLTRB(12, 8, 12, 8 + bottom),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
enabled: enabled,
maxLines: 4,
minLines: 1,
textInputAction: TextInputAction.send,
onSubmitted: (_) => onSend(),
decoration: InputDecoration(
hintText: streaming ? 'Yanit bekleniyor...' : 'Bir sey sorun...',
hintStyle: const TextStyle(color: AppColors.textMuted),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(color: AppColors.accent, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
isDense: true,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: enabled ? onSend : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 40,
height: 40,
decoration: BoxDecoration(
color: enabled ? AppColors.accent : AppColors.border,
shape: BoxShape.circle,
),
child: Center(
child: streaming
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.arrow_upward_rounded, color: Colors.white, size: 18),
),
),
),
],
),
);
}
}
// ── Loading context ───────────────────────────────────────────────────────────
class _LoadingContext extends StatelessWidget {
const _LoadingContext();
@override
Widget build(BuildContext context) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppColors.accent, strokeWidth: 2),
SizedBox(height: 12),
Text('Veriler yukleniyor...', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
],
),
);
}
}