Files
lab-app/lib/features/shared/ai_chat_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

931 lines
30 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_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)),
],
),
);
}
}