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,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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/job.dart';
|
||||
import '../../models/job_file.dart';
|
||||
import 'job_files_repository.dart';
|
||||
|
||||
const _maxFileSizeBytes = 50 * 1024 * 1024; // 50 MB per file
|
||||
const _maxFilesPerJob = 10;
|
||||
|
||||
class JobFilesPanel extends StatefulWidget {
|
||||
const JobFilesPanel({
|
||||
super.key,
|
||||
required this.job,
|
||||
required this.filesFuture,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final Job job;
|
||||
final Future<List<JobFile>> filesFuture;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
@override
|
||||
State<JobFilesPanel> createState() => _JobFilesPanelState();
|
||||
}
|
||||
|
||||
class _JobFilesPanelState extends State<JobFilesPanel> {
|
||||
_UploadState? _upload;
|
||||
List<JobFile>? _files;
|
||||
bool _loadingFiles = false;
|
||||
String? _filesError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscribeToFuture(widget.filesFuture);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(JobFilesPanel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.filesFuture != widget.filesFuture) {
|
||||
_subscribeToFuture(widget.filesFuture);
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToFuture(Future<List<JobFile>> future) {
|
||||
setState(() { _loadingFiles = true; _filesError = null; });
|
||||
future.then((files) {
|
||||
if (mounted) setState(() { _files = files; _loadingFiles = false; });
|
||||
}).catchError((e) {
|
||||
if (mounted) setState(() { _filesError = _friendlyError(e); _loadingFiles = false; });
|
||||
});
|
||||
}
|
||||
|
||||
static String _friendlyError(Object e) {
|
||||
final s = e.toString();
|
||||
// Strip full ClientException URL dumps — show only the message part
|
||||
final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s);
|
||||
if (msgMatch != null) return msgMatch.group(1)!.trim();
|
||||
if (s.length > 100) return 'Sunucu hatası';
|
||||
return s;
|
||||
}
|
||||
|
||||
Future<void> _pickAndUpload() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: true,
|
||||
withData: true,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: [
|
||||
'pdf', 'jpg', 'jpeg', 'png', 'webp',
|
||||
'stl', 'obj', 'ply', 'zip', 'opus', 'mp3', 'mp4'
|
||||
],
|
||||
);
|
||||
if (result == null || result.files.isEmpty || !mounted) return;
|
||||
|
||||
// Client-side size validation
|
||||
for (final pf in result.files) {
|
||||
if (pf.size > _maxFileSizeBytes) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('${pf.name} 50 MB sınırını aşıyor (${_formatSize(pf.size)}).'),
|
||||
backgroundColor: AppColors.cancelled,
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final existingCount = _files?.length ?? 0;
|
||||
final remaining = _maxFilesPerJob - existingCount;
|
||||
if (result.files.length > remaining) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Bu iş en fazla $_maxFilesPerJob dosya alabilir. Şu an $existingCount dosya var; en fazla $remaining dosya daha ekleyebilirsiniz.'),
|
||||
backgroundColor: AppColors.cancelled,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
int uploadedCount = 0;
|
||||
for (var i = 0; i < result.files.length; i++) {
|
||||
final pf = result.files[i];
|
||||
if (pf.bytes == null) continue;
|
||||
|
||||
setState(() {
|
||||
_upload = _UploadState(
|
||||
fileName: pf.name,
|
||||
fileIndex: i + 1,
|
||||
totalFiles: result.files.length,
|
||||
progress: 0,
|
||||
speedBytesPerSec: 0,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await _uploadWithProgress(
|
||||
pf: pf,
|
||||
onProgress: (progress, speed) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_upload = _upload?.copyWith(progress: progress, speedBytesPerSec: speed);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
uploadedCount++;
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${pf.name} yüklenemedi: ${_friendlyError(e)}'), backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
setState(() => _upload = null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _upload = null);
|
||||
widget.onRefresh();
|
||||
if (mounted && uploadedCount > 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$uploadedCount dosya yüklendi.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadWithProgress({
|
||||
required PlatformFile pf,
|
||||
required void Function(double progress, double speedBytesPerSec) onProgress,
|
||||
}) async {
|
||||
final bytes = pf.bytes!;
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final baseUrl = 'https://pocket.kovaksoft.com';
|
||||
final uri = Uri.parse('$baseUrl/api/collections/job_files/records');
|
||||
|
||||
final ext = (pf.extension ?? '').toLowerCase();
|
||||
final kind = (ext == 'stl' || ext == 'obj' || ext == 'ply')
|
||||
? JobFileKind.scan
|
||||
: (ext == 'pdf') ? JobFileKind.document : JobFileKind.image;
|
||||
final mimeType = _mimeFromExt(ext);
|
||||
final currentUserId = (pb.authStore.record?.id) ?? '';
|
||||
final token = pb.authStore.token;
|
||||
|
||||
final startTime = DateTime.now().millisecondsSinceEpoch;
|
||||
int sentBytes = 0;
|
||||
|
||||
Stream<List<int>> progressStream(List<int> src) async* {
|
||||
const chunkSize = 65536;
|
||||
var offset = 0;
|
||||
while (offset < src.length) {
|
||||
final end = (offset + chunkSize).clamp(0, src.length);
|
||||
final chunk = src.sublist(offset, end);
|
||||
yield chunk;
|
||||
offset = end;
|
||||
sentBytes = offset;
|
||||
final elapsedMs = DateTime.now().millisecondsSinceEpoch - startTime;
|
||||
final speed = elapsedMs > 0 ? sentBytes / elapsedMs * 1000 : 0.0;
|
||||
onProgress(sentBytes / src.length, speed);
|
||||
// yield control so Flutter can rebuild
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
final request = http.MultipartRequest('POST', uri)
|
||||
..headers['Authorization'] = 'Bearer $token'
|
||||
..fields['job_id'] = widget.job.id
|
||||
..fields['clinic_tenant_id'] = widget.job.clinicTenantId
|
||||
..fields['lab_tenant_id'] = widget.job.labTenantId
|
||||
..fields['uploaded_by'] = currentUserId
|
||||
..fields['kind'] = kind.value
|
||||
..fields['name'] = pf.name
|
||||
..fields['size'] = bytes.length.toString()
|
||||
..fields['mime_type'] = mimeType
|
||||
..files.add(http.MultipartFile(
|
||||
'file',
|
||||
http.ByteStream(progressStream(bytes)),
|
||||
bytes.length,
|
||||
filename: pf.name,
|
||||
));
|
||||
|
||||
final streamed = await request.send();
|
||||
final body = await streamed.stream.bytesToString();
|
||||
|
||||
if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
|
||||
String msg = 'HTTP ${streamed.statusCode}';
|
||||
try {
|
||||
final j = jsonDecode(body) as Map<String, dynamic>;
|
||||
msg = j['message'] as String? ?? msg;
|
||||
} catch (_) {}
|
||||
throw Exception(msg);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkDownload(List<JobFile> files) async {
|
||||
if (files.isEmpty) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final fileToken = await pb.files.getToken();
|
||||
final dir = await getTemporaryDirectory();
|
||||
await dir.create(recursive: true);
|
||||
|
||||
// Download all files in parallel
|
||||
final results = await Future.wait(
|
||||
files.where((f) => f.downloadUrl.isNotEmpty).map((file) async {
|
||||
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
|
||||
final response = await http.get(uri);
|
||||
if (response.statusCode != 200) return null;
|
||||
final path = '${dir.path}/${file.name}';
|
||||
await File(path).writeAsBytes(response.bodyBytes);
|
||||
return XFile(path, mimeType: file.mimeType ?? 'application/octet-stream');
|
||||
}),
|
||||
);
|
||||
|
||||
final xFiles = results.whereType<XFile>().toList();
|
||||
if (xFiles.isEmpty) return;
|
||||
await Share.shareXFiles(
|
||||
xFiles,
|
||||
subject: '${widget.job.patientCode} dosyaları',
|
||||
sharePositionOrigin: const Rect.fromLTWH(100, 100, 200, 1),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('İndirilemedi: $e'), backgroundColor: AppColors.cancelled),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFile(JobFile file) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Dosyayı Sil'),
|
||||
content: Text(file.name),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('İptal')),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Sil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
// Optimistic: remove immediately from local list
|
||||
setState(() => _files = _files?.where((f) => f.id != file.id).toList());
|
||||
|
||||
try {
|
||||
await JobFilesRepository.instance.deleteFile(file.id);
|
||||
} catch (e) {
|
||||
final is404 = e.toString().contains('404') || e.toString().contains('wasn\'t found');
|
||||
if (!is404) {
|
||||
// Revert only on transient errors (network, 500) — not when already deleted
|
||||
setState(() => _files = [...?_files, file]);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(is404 ? 'Dosya zaten silinmiş.' : 'Silinemedi: ${_friendlyError(e)}'),
|
||||
backgroundColor: AppColors.cancelled,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _downloadFile(JobFile file, Rect shareOrigin) async {
|
||||
if (file.downloadUrl.isEmpty) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final pb = PocketBaseClient.instance.pb;
|
||||
final fileToken = await pb.files.getToken();
|
||||
final uri = Uri.parse('${file.downloadUrl}?token=$fileToken');
|
||||
final response = await http.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('HTTP ${response.statusCode}');
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
await dir.create(recursive: true);
|
||||
final path = '${dir.path}/${file.name}';
|
||||
await File(path).writeAsBytes(response.bodyBytes);
|
||||
await Share.shareXFiles(
|
||||
[XFile(path, mimeType: file.mimeType ?? 'application/octet-stream')],
|
||||
subject: file.name,
|
||||
sharePositionOrigin: shareOrigin,
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('İndirilemedi: $e'),
|
||||
backgroundColor: AppColors.cancelled,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uploading = _upload != null;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_file_rounded, size: 18, color: AppColors.accent),
|
||||
const SizedBox(width: 6),
|
||||
const Expanded(
|
||||
child: Text('Dosyalar', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
|
||||
),
|
||||
if (!uploading) ...[
|
||||
if ((_files?.length ?? 0) >= 2)
|
||||
TextButton.icon(
|
||||
onPressed: () => _bulkDownload(_files!),
|
||||
icon: const Icon(Icons.download_for_offline_outlined, size: 16),
|
||||
label: const Text('Hepsini İndir'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: _pickAndUpload,
|
||||
icon: const Icon(Icons.upload_rounded, size: 16),
|
||||
label: const Text('Yükle'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
if (_upload != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_UploadProgressBar(state: _upload!),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
Builder(builder: (ctx) {
|
||||
if (_loadingFiles && _files == null) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
}
|
||||
if (_filesError != null && _files == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'Dosyalar yüklenemedi: $_filesError',
|
||||
style: const TextStyle(color: AppColors.cancelled, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
final files = _files ?? [];
|
||||
if (files.isEmpty && !uploading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'Henüz dosya eklenmemiş.',
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: files
|
||||
.map((f) => _FileRow(
|
||||
file: f,
|
||||
onDelete: () => _deleteFile(f),
|
||||
onDownload: (origin) => _downloadFile(f, origin),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
static String _mimeFromExt(String ext) => switch (ext) {
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
'pdf' => 'application/pdf',
|
||||
'stl' => 'model/stl',
|
||||
'zip' => 'application/zip',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'mp4' => 'video/mp4',
|
||||
'opus' => 'audio/opus',
|
||||
_ => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Upload Progress Bar ───────────────────────────────────────────────────────
|
||||
|
||||
class _UploadProgressBar extends StatelessWidget {
|
||||
const _UploadProgressBar({required this.state});
|
||||
final _UploadState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pct = (state.progress * 100).toStringAsFixed(0);
|
||||
final speed = state.speedBytesPerSec;
|
||||
final speedLabel = speed >= 1024 * 1024
|
||||
? '${(speed / 1024 / 1024).toStringAsFixed(1)} MB/s'
|
||||
: '${(speed / 1024).toStringAsFixed(0)} KB/s';
|
||||
|
||||
final fileLabel = state.totalFiles > 1
|
||||
? '${state.fileIndex}/${state.totalFiles} — ${state.fileName}'
|
||||
: state.fileName;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
fileLabel,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$pct% · $speedLabel',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: state.progress,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.background,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── File Row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FileRow extends StatefulWidget {
|
||||
const _FileRow({
|
||||
required this.file,
|
||||
required this.onDelete,
|
||||
required this.onDownload,
|
||||
});
|
||||
final JobFile file;
|
||||
final VoidCallback onDelete;
|
||||
final Future<void> Function(Rect) onDownload;
|
||||
|
||||
@override
|
||||
State<_FileRow> createState() => _FileRowState();
|
||||
}
|
||||
|
||||
class _FileRowState extends State<_FileRow> {
|
||||
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,
|
||||
};
|
||||
|
||||
final _downloadKey = GlobalKey();
|
||||
|
||||
Future<void> _handleDownload() async {
|
||||
setState(() => _downloading = true);
|
||||
final box = _downloadKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final origin = box != null
|
||||
? box.localToGlobal(Offset.zero) & box.size
|
||||
: const Rect.fromLTWH(100, 100, 200, 1);
|
||||
try {
|
||||
await widget.onDownload(origin);
|
||||
} finally {
|
||||
if (mounted) setState(() => _downloading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_icon, size: 18, color: AppColors.textMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.file.name,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'${widget.file.kind.label} · ${widget.file.sizeLabel}',
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_downloading)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.accent),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
key: _downloadKey,
|
||||
onPressed: _handleDownload,
|
||||
icon: const Icon(Icons.download_outlined, size: 18, color: AppColors.accent),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: 'İndir',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: widget.onDelete,
|
||||
icon: const Icon(Icons.delete_outline_rounded, size: 18, color: AppColors.cancelled),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: 'Sil',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upload State ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _UploadState {
|
||||
const _UploadState({
|
||||
required this.fileName,
|
||||
required this.fileIndex,
|
||||
required this.totalFiles,
|
||||
required this.progress,
|
||||
required this.speedBytesPerSec,
|
||||
});
|
||||
|
||||
final String fileName;
|
||||
final int fileIndex;
|
||||
final int totalFiles;
|
||||
final double progress;
|
||||
final double speedBytesPerSec;
|
||||
|
||||
_UploadState copyWith({double? progress, double? speedBytesPerSec}) =>
|
||||
_UploadState(
|
||||
fileName: fileName,
|
||||
fileIndex: fileIndex,
|
||||
totalFiles: totalFiles,
|
||||
progress: progress ?? this.progress,
|
||||
speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../models/job_file.dart';
|
||||
|
||||
class JobFilesRepository {
|
||||
JobFilesRepository._();
|
||||
static final instance = JobFilesRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
static const _baseUrl = 'https://pocket.kovaksoft.com';
|
||||
|
||||
String get _currentUserId => (_pb.authStore.record?.id) ?? '';
|
||||
|
||||
Future<List<JobFile>> listForJob(String jobId) async {
|
||||
final result = await _pb.collection('job_files').getList(
|
||||
filter: 'job_id = "$jobId"',
|
||||
perPage: 200,
|
||||
);
|
||||
final files = result.items.map((r) => JobFile.fromJson(r.toJson(), _baseUrl)).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
return files;
|
||||
}
|
||||
|
||||
Future<JobFile> uploadFile({
|
||||
required String jobId,
|
||||
required String clinicTenantId,
|
||||
required String labTenantId,
|
||||
required JobFileKind kind,
|
||||
required String name,
|
||||
required int size,
|
||||
required List<int> bytes,
|
||||
String? mimeType,
|
||||
}) async {
|
||||
final multipartFile = http.MultipartFile.fromBytes(
|
||||
'file',
|
||||
bytes,
|
||||
filename: name,
|
||||
);
|
||||
final record = await _pb.collection('job_files').create(
|
||||
body: {
|
||||
'job_id': jobId,
|
||||
'clinic_tenant_id': clinicTenantId,
|
||||
'lab_tenant_id': labTenantId,
|
||||
'uploaded_by': _currentUserId,
|
||||
'kind': kind.value,
|
||||
'name': name,
|
||||
'size': size,
|
||||
if (mimeType != null) 'mime_type': mimeType,
|
||||
},
|
||||
files: [multipartFile],
|
||||
);
|
||||
return JobFile.fromJson(record.toJson(), _baseUrl);
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String fileId) async {
|
||||
await _pb.collection('job_files').delete(fileId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../models/tenant.dart';
|
||||
|
||||
// ── Value objects ─────────────────────────────────────────────────────────────
|
||||
|
||||
const _monthLabels = ['Oca','Şub','Mar','Nis','May','Haz','Tem','Ağu','Eyl','Eki','Kas','Ara'];
|
||||
|
||||
class MonthlyCount {
|
||||
const MonthlyCount({required this.year, required this.month, required this.count});
|
||||
final int year, month, count;
|
||||
String get label => _monthLabels[month - 1];
|
||||
}
|
||||
|
||||
class MonthlyAmount {
|
||||
const MonthlyAmount({required this.year, required this.month, required this.amount});
|
||||
final int year, month;
|
||||
final double amount;
|
||||
String get label => _monthLabels[month - 1];
|
||||
}
|
||||
|
||||
class CounterpartStat {
|
||||
const CounterpartStat({required this.name, required this.jobCount, required this.pendingRevenue, required this.paidRevenue});
|
||||
final String name;
|
||||
final int jobCount;
|
||||
final double pendingRevenue, paidRevenue;
|
||||
double get totalRevenue => pendingRevenue + paidRevenue;
|
||||
}
|
||||
|
||||
class ActivityItem {
|
||||
const ActivityItem({required this.jobId, this.patientCode, required this.action, required this.createdAt, this.note});
|
||||
final String jobId, action;
|
||||
final String? patientCode, note;
|
||||
final DateTime createdAt;
|
||||
|
||||
String get actionLabel => switch (action) {
|
||||
'accepted' => 'İş kabul edildi',
|
||||
'handed_to_clinic' => 'Provaya gönderildi',
|
||||
'approved' => 'Onaylandı',
|
||||
'revision_requested' => 'Revizyon istendi',
|
||||
'delivered' => 'Teslim edildi',
|
||||
'cancelled' => 'İptal edildi',
|
||||
_ => action,
|
||||
};
|
||||
|
||||
bool get isNegative => action == 'revision_requested' || action == 'cancelled';
|
||||
bool get isPositive => action == 'delivered' || action == 'approved' || action == 'accepted';
|
||||
}
|
||||
|
||||
// ── Aggregated metrics ────────────────────────────────────────────────────────
|
||||
|
||||
class ReportMetrics {
|
||||
const ReportMetrics({
|
||||
required this.activeJobs,
|
||||
required this.completedThisMonth,
|
||||
required this.overdueJobs,
|
||||
required this.revisionRate,
|
||||
required this.avgCompletionDays,
|
||||
required this.totalRevenue,
|
||||
required this.pendingRevenue,
|
||||
required this.currency,
|
||||
required this.jobsByStatus,
|
||||
required this.monthlyCounts,
|
||||
required this.monthlyRevenue,
|
||||
required this.byProstheticType,
|
||||
required this.counterpartStats,
|
||||
required this.recentActivity,
|
||||
});
|
||||
|
||||
final int activeJobs;
|
||||
final int completedThisMonth;
|
||||
final int overdueJobs;
|
||||
final double revisionRate; // 0-100
|
||||
final double avgCompletionDays;
|
||||
final double totalRevenue;
|
||||
final double pendingRevenue;
|
||||
final String currency;
|
||||
|
||||
final Map<String, int> jobsByStatus;
|
||||
final List<MonthlyCount> monthlyCounts;
|
||||
final List<MonthlyAmount> monthlyRevenue;
|
||||
final Map<String, int> byProstheticType;
|
||||
final List<CounterpartStat> counterpartStats;
|
||||
final List<ActivityItem> recentActivity;
|
||||
}
|
||||
|
||||
// ── Repository ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ReportsRepository {
|
||||
ReportsRepository._();
|
||||
static final instance = ReportsRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<ReportMetrics> load(String tenantId, TenantKind kind) async {
|
||||
final jobFilter = kind == TenantKind.lab
|
||||
? 'lab_tenant_id = "$tenantId"'
|
||||
: 'clinic_tenant_id = "$tenantId"';
|
||||
final historyFilter = kind == TenantKind.lab
|
||||
? 'lab_tenant_id = "$tenantId"'
|
||||
: 'clinic_tenant_id = "$tenantId"';
|
||||
|
||||
final results = await Future.wait([
|
||||
_pb.collection('jobs').getList(
|
||||
filter: jobFilter,
|
||||
perPage: 500,
|
||||
expand: kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id',
|
||||
fields: 'id,status,prosthetic_type,clinic_tenant_id,lab_tenant_id,created,updated,due_date,price,currency,expand',
|
||||
).catchError((_) => ResultList<RecordModel>()),
|
||||
_pb.collection('finance_entries').getList(
|
||||
filter: 'tenant_id = "$tenantId"',
|
||||
perPage: 300,
|
||||
fields: 'id,amount,currency,status,created,counterparty_name',
|
||||
).catchError((_) => ResultList<RecordModel>()),
|
||||
_pb.collection('job_status_history').getList(
|
||||
filter: historyFilter,
|
||||
perPage: 100,
|
||||
expand: 'job_id',
|
||||
fields: 'id,action_type,created,note,job_id,expand',
|
||||
).catchError((_) => ResultList<RecordModel>()),
|
||||
]);
|
||||
|
||||
final jobRecords = (results[0] as ResultList<RecordModel>).items;
|
||||
final financeRecords = (results[1] as ResultList<RecordModel>).items;
|
||||
final historyRecords = (results[2] as ResultList<RecordModel>).items;
|
||||
|
||||
return _aggregate(tenantId, kind, jobRecords, financeRecords, historyRecords);
|
||||
}
|
||||
|
||||
ReportMetrics _aggregate(
|
||||
String tenantId,
|
||||
TenantKind kind,
|
||||
List<RecordModel> jobRecords,
|
||||
List<RecordModel> financeRecords,
|
||||
List<RecordModel> historyRecords,
|
||||
) {
|
||||
final now = DateTime.now();
|
||||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||||
|
||||
// ── Parse jobs ────────────────────────────────────────────────────────────
|
||||
String _s(Map<String, dynamic> j, String k) {
|
||||
final v = j[k];
|
||||
if (v == null || v == '') return '';
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
final jobs = jobRecords.map((r) {
|
||||
final j = r.toJson();
|
||||
final exp = j['expand'] as Map<String, dynamic>?;
|
||||
final cpKey = kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id';
|
||||
final cpExp = exp?[cpKey] as Map<String, dynamic>?;
|
||||
return _RawJob(
|
||||
id: _s(j, 'id'),
|
||||
status: _s(j, 'status'),
|
||||
prostheticType: _s(j, 'prosthetic_type'),
|
||||
clinicTenantId: _s(j, 'clinic_tenant_id'),
|
||||
labTenantId: _s(j, 'lab_tenant_id'),
|
||||
created: _parseDate(j['created']),
|
||||
updated: _parseDate(j['updated']),
|
||||
dueDate: j['due_date'] != null && j['due_date'] != '' ? _parseDate(j['due_date']) : null,
|
||||
currency: _s(j, 'currency').isNotEmpty ? _s(j, 'currency') : 'TRY',
|
||||
counterpartName: cpExp?['company_name'] as String? ?? '',
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// ── Parse finance ─────────────────────────────────────────────────────────
|
||||
String topCurrency = 'TRY';
|
||||
final financeList = financeRecords.map((r) {
|
||||
final j = r.toJson();
|
||||
final cur = (j['currency'] as String?) ?? 'TRY';
|
||||
if (cur.isNotEmpty) topCurrency = cur;
|
||||
return _RawFinance(
|
||||
status: (j['status'] as String?) ?? '',
|
||||
amount: (j['amount'] as num?)?.toDouble() ?? 0,
|
||||
created: _parseDate(j['created']),
|
||||
counterpartyName: j['counterparty_name'] as String?,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// ── Parse history ─────────────────────────────────────────────────────────
|
||||
final activity = historyRecords.map((r) {
|
||||
final j = r.toJson();
|
||||
final exp = j['expand'] as Map<String, dynamic>?;
|
||||
final jobExp = exp?['job_id'] as Map<String, dynamic>?;
|
||||
return ActivityItem(
|
||||
jobId: (j['job_id'] as String?) ?? '',
|
||||
patientCode: jobExp?['patient_code'] as String?,
|
||||
action: (j['action_type'] as String?) ?? '',
|
||||
createdAt: _parseDate(j['created']),
|
||||
note: j['note'] as String?,
|
||||
);
|
||||
}).toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
|
||||
// ── KPI metrics ───────────────────────────────────────────────────────────
|
||||
final activeJobs = jobs.where((j) => j.status == 'in_progress' || j.status == 'pending').length;
|
||||
final completedThisMonth = jobs.where((j) => j.status == 'delivered' && j.updated.isAfter(thisMonthStart)).length;
|
||||
final overdueJobs = jobs.where((j) =>
|
||||
j.dueDate != null &&
|
||||
j.dueDate!.isBefore(now) &&
|
||||
(j.status == 'in_progress' || j.status == 'pending')).length;
|
||||
|
||||
final revisions = activity.where((a) => a.action == 'revision_requested').length;
|
||||
final revisionRate = activity.isNotEmpty ? revisions / activity.length * 100 : 0.0;
|
||||
|
||||
final deliveredJobs = jobs.where((j) => j.status == 'delivered').toList();
|
||||
final avgCompletionDays = deliveredJobs.isNotEmpty
|
||||
? deliveredJobs
|
||||
.fold<int>(0, (s, j) => s + j.updated.difference(j.created).inDays) /
|
||||
deliveredJobs.length
|
||||
: 0.0;
|
||||
|
||||
// ── Finance totals ────────────────────────────────────────────────────────
|
||||
final totalRevenue = financeList.where((f) => f.status == 'paid').fold<double>(0, (s, f) => s + f.amount);
|
||||
final pendingRevenue = financeList.where((f) => f.status == 'pending').fold<double>(0, (s, f) => s + f.amount);
|
||||
|
||||
// ── Job status distribution ───────────────────────────────────────────────
|
||||
final Map<String, int> jobsByStatus = {};
|
||||
for (final j in jobs) {
|
||||
jobsByStatus[j.status] = (jobsByStatus[j.status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// ── Monthly job counts (last 6 months) ────────────────────────────────────
|
||||
final monthKeys = List.generate(6, (i) {
|
||||
final d = DateTime(now.year, now.month - 5 + i, 1);
|
||||
return '${d.year}-${d.month}';
|
||||
});
|
||||
final monthMap = {for (final k in monthKeys) k: 0};
|
||||
for (final j in jobs) {
|
||||
final key = '${j.created.year}-${j.created.month}';
|
||||
if (monthMap.containsKey(key)) monthMap[key] = monthMap[key]! + 1;
|
||||
}
|
||||
final monthlyCounts = monthKeys.map((k) {
|
||||
final parts = k.split('-');
|
||||
return MonthlyCount(year: int.parse(parts[0]), month: int.parse(parts[1]), count: monthMap[k]!);
|
||||
}).toList();
|
||||
|
||||
// ── Monthly revenue (last 6 months) ──────────────────────────────────────
|
||||
final revMap = {for (final k in monthKeys) k: 0.0};
|
||||
for (final f in financeList) {
|
||||
if (f.status == 'paid') {
|
||||
final key = '${f.created.year}-${f.created.month}';
|
||||
if (revMap.containsKey(key)) revMap[key] = revMap[key]! + f.amount;
|
||||
}
|
||||
}
|
||||
final monthlyRevenue = monthKeys.map((k) {
|
||||
final parts = k.split('-');
|
||||
return MonthlyAmount(year: int.parse(parts[0]), month: int.parse(parts[1]), amount: revMap[k]!);
|
||||
}).toList();
|
||||
|
||||
// ── By prosthetic type ────────────────────────────────────────────────────
|
||||
final Map<String, int> byType = {};
|
||||
for (final j in jobs) {
|
||||
if (j.prostheticType.isNotEmpty) {
|
||||
byType[j.prostheticType] = (byType[j.prostheticType] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── By counterpart ────────────────────────────────────────────────────────
|
||||
final Map<String, int> cpCount = {};
|
||||
final Map<String, double> cpPending = {}, cpPaid = {};
|
||||
for (final j in jobs) {
|
||||
final name = j.counterpartName.isNotEmpty ? j.counterpartName : '—';
|
||||
cpCount[name] = (cpCount[name] ?? 0) + 1;
|
||||
}
|
||||
for (final f in financeList) {
|
||||
final name = f.counterpartyName ?? '—';
|
||||
if (f.status == 'pending') cpPending[name] = (cpPending[name] ?? 0) + f.amount;
|
||||
if (f.status == 'paid') cpPaid[name] = (cpPaid[name] ?? 0) + f.amount;
|
||||
}
|
||||
final counterparts = cpCount.entries
|
||||
.map((e) => CounterpartStat(
|
||||
name: e.key,
|
||||
jobCount: e.value,
|
||||
pendingRevenue: cpPending[e.key] ?? 0,
|
||||
paidRevenue: cpPaid[e.key] ?? 0,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => b.jobCount.compareTo(a.jobCount));
|
||||
|
||||
return ReportMetrics(
|
||||
activeJobs: activeJobs,
|
||||
completedThisMonth: completedThisMonth,
|
||||
overdueJobs: overdueJobs,
|
||||
revisionRate: revisionRate,
|
||||
avgCompletionDays: avgCompletionDays,
|
||||
totalRevenue: totalRevenue,
|
||||
pendingRevenue: pendingRevenue,
|
||||
currency: topCurrency,
|
||||
jobsByStatus: jobsByStatus,
|
||||
monthlyCounts: monthlyCounts,
|
||||
monthlyRevenue: monthlyRevenue,
|
||||
byProstheticType: byType,
|
||||
counterpartStats: counterparts.take(5).toList(),
|
||||
recentActivity: activity.take(30).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime _parseDate(dynamic v) {
|
||||
if (v == null || v == '') return DateTime(2000);
|
||||
return DateTime.tryParse(v.toString()) ?? DateTime(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal raw models ───────────────────────────────────────────────────────
|
||||
|
||||
class _RawJob {
|
||||
const _RawJob({
|
||||
required this.id, required this.status, required this.prostheticType,
|
||||
required this.clinicTenantId, required this.labTenantId,
|
||||
required this.created, required this.updated,
|
||||
required this.currency, required this.counterpartName, this.dueDate,
|
||||
});
|
||||
final String id, status, prostheticType, clinicTenantId, labTenantId, currency, counterpartName;
|
||||
final DateTime created, updated;
|
||||
final DateTime? dueDate;
|
||||
}
|
||||
|
||||
class _RawFinance {
|
||||
const _RawFinance({required this.status, required this.amount, required this.created, this.counterpartyName});
|
||||
final String status;
|
||||
final double amount;
|
||||
final DateTime created;
|
||||
final String? counterpartyName;
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/providers/auth_provider.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/gradient_app_bar.dart';
|
||||
import '../../models/job.dart';
|
||||
import '../../models/tenant.dart';
|
||||
import 'reports_repository.dart';
|
||||
|
||||
class ReportsScreen extends ConsumerStatefulWidget {
|
||||
const ReportsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ReportsScreen> createState() => _ReportsScreenState();
|
||||
}
|
||||
|
||||
class _ReportsScreenState extends ConsumerState<ReportsScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late Future<ReportMetrics> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
_tabController.addListener(() { if (mounted) setState(() {}); });
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final auth = ref.read(authProvider);
|
||||
final tenantId = auth.activeTenant!.tenant.id;
|
||||
final kind = auth.activeTenant!.tenant.kind;
|
||||
setState(() {
|
||||
_future = ReportsRepository.instance.load(tenantId, kind);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kind = ref.watch(authProvider).activeTenant?.tenant.kind ?? TenantKind.lab;
|
||||
final counterpartLabel = kind == TenantKind.lab ? 'Klinikler' : 'Laboratuvarlar';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: GradientAppBar(
|
||||
title: 'Raporlar',
|
||||
category: 'YÖNETİCİ',
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
onPressed: _load,
|
||||
tooltip: 'Yenile',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<ReportMetrics>(
|
||||
future: _future,
|
||||
builder: (context, 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: [
|
||||
const Icon(Icons.error_outline_rounded, color: AppColors.cancelled, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text('Veriler yüklenemedi', 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 m = snap.data!;
|
||||
return Column(
|
||||
children: [
|
||||
// Tab bar
|
||||
Container(
|
||||
color: AppColors.surface,
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.accent,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.accent,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
labelStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
tabs: [
|
||||
const Tab(text: 'Özet'),
|
||||
const Tab(text: 'Finans'),
|
||||
const Tab(text: 'Aktivite'),
|
||||
Tab(text: counterpartLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_SummaryTab(metrics: m),
|
||||
_FinanceTab(metrics: m),
|
||||
_ActivityTab(metrics: m),
|
||||
_CounterpartTab(metrics: m, label: counterpartLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared layout helpers ─────────────────────────────────────────────────────
|
||||
|
||||
class _TabBody extends StatelessWidget {
|
||||
const _TabBody({required this.children});
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.child, this.padding = const EdgeInsets.all(16)});
|
||||
final Widget child;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader(this.title, {this.subtitle});
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!, style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI Chips ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _KpiRow extends StatelessWidget {
|
||||
const _KpiRow({required this.metrics});
|
||||
final ReportMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_Kpi(label: 'Aktif İşler', value: '${metrics.activeJobs}', icon: Icons.work_outline_rounded, color: AppColors.inProgress),
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Bu Ay Tamamlandı', value: '${metrics.completedThisMonth}', icon: Icons.check_circle_outline_rounded, color: AppColors.success),
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Bekleyen Gelir', value: fmt.format(metrics.pendingRevenue), icon: Icons.hourglass_empty_rounded, color: AppColors.pending),
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Ort. Süre', value: '${metrics.avgCompletionDays.toStringAsFixed(1)} gün', icon: Icons.timer_outlined, color: AppColors.accent),
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Revizyon Oranı', value: '%${metrics.revisionRate.toStringAsFixed(0)}', icon: Icons.loop_rounded, color: metrics.revisionRate > 20 ? AppColors.cancelled : AppColors.textSecondary),
|
||||
if (metrics.overdueJobs > 0) ...[
|
||||
const SizedBox(width: 10),
|
||||
_Kpi(label: 'Gecikmiş', value: '${metrics.overdueJobs}', icon: Icons.schedule_rounded, color: AppColors.cancelled),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Kpi extends StatelessWidget {
|
||||
const _Kpi({required this.label, required this.value, required this.icon, required this.color});
|
||||
final String label, value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 6)],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Özet Tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SummaryTab extends StatelessWidget {
|
||||
const _SummaryTab({required this.metrics});
|
||||
final ReportMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _TabBody(children: [
|
||||
_KpiRow(metrics: metrics),
|
||||
const _SectionHeader('İş Durumu Dağılımı'),
|
||||
_Card(
|
||||
child: Column(
|
||||
children: _statusOrder.where((s) => metrics.jobsByStatus.containsKey(s)).map((s) {
|
||||
final count = metrics.jobsByStatus[s] ?? 0;
|
||||
final total = metrics.jobsByStatus.values.fold(0, (a, b) => a + b);
|
||||
return _HBarRow(
|
||||
label: _statusLabel(s),
|
||||
value: count,
|
||||
max: total,
|
||||
color: _statusColor(s),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const _SectionHeader('Son 6 Aylık İş Trendi'),
|
||||
_Card(child: _VBarChart(data: metrics.monthlyCounts, color: AppColors.accent)),
|
||||
]);
|
||||
}
|
||||
|
||||
static const _statusOrder = ['in_progress', 'pending', 'sent', 'delivered', 'cancelled'];
|
||||
|
||||
static String _statusLabel(String s) => switch (s) {
|
||||
'pending' => 'Bekliyor',
|
||||
'in_progress' => 'İşlemde',
|
||||
'sent' => 'Gönderildi',
|
||||
'delivered' => 'Teslim',
|
||||
'cancelled' => 'İptal',
|
||||
_ => s,
|
||||
};
|
||||
|
||||
static Color _statusColor(String s) => switch (s) {
|
||||
'pending' => AppColors.pending,
|
||||
'in_progress' => AppColors.inProgress,
|
||||
'sent' => AppColors.accent,
|
||||
'delivered' => AppColors.success,
|
||||
'cancelled' => AppColors.cancelled,
|
||||
_ => AppColors.textMuted,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Finans Tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FinanceTab extends StatelessWidget {
|
||||
const _FinanceTab({required this.metrics});
|
||||
final ReportMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||||
final total = metrics.totalRevenue + metrics.pendingRevenue;
|
||||
return _TabBody(children: [
|
||||
const _SectionHeader('Gelir Özeti'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.success, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Tahsil Edildi', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(fmt.format(metrics.totalRevenue),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.success)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: AppColors.pending, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
const Text('Bekleyen', style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(fmt.format(metrics.pendingRevenue),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.pending)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (total > 0) _Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Tahsilat Oranı', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: total > 0 ? metrics.totalRevenue / total : 0,
|
||||
minHeight: 12,
|
||||
backgroundColor: AppColors.pendingBg,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.success),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('${(metrics.totalRevenue / total * 100).toStringAsFixed(0)}% tahsil edildi',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const _SectionHeader('Aylık Gelir Trendi'),
|
||||
_Card(child: _VBarChart(
|
||||
data: metrics.monthlyRevenue.map((m) => MonthlyCount(year: m.year, month: m.month, count: m.amount.round())).toList(),
|
||||
color: AppColors.success,
|
||||
formatValue: (v) => NumberFormat.compactCurrency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0).format(v),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aktivite Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _ActivityTab extends StatelessWidget {
|
||||
const _ActivityTab({required this.metrics});
|
||||
final ReportMetrics metrics;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = metrics.recentActivity;
|
||||
if (items.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Henüz aktivite kaydı yok.', style: TextStyle(color: AppColors.textMuted)),
|
||||
);
|
||||
}
|
||||
return _TabBody(children: [
|
||||
const _SectionHeader('Son İşlemler'),
|
||||
_Card(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
children: items.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final item = entry.value;
|
||||
return _ActivityRow(item: item, isLast: i == items.length - 1);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityRow extends StatelessWidget {
|
||||
const _ActivityRow({required this.item, required this.isLast});
|
||||
final ActivityItem item;
|
||||
final bool isLast;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = item.isNegative ? AppColors.cancelled : item.isPositive ? AppColors.success : AppColors.accent;
|
||||
final icon = switch (item.action) {
|
||||
'accepted' => Icons.check_circle_outline_rounded,
|
||||
'handed_to_clinic' => Icons.send_rounded,
|
||||
'approved' => Icons.thumb_up_outlined,
|
||||
'revision_requested' => Icons.loop_rounded,
|
||||
'delivered' => Icons.local_shipping_outlined,
|
||||
'cancelled' => Icons.cancel_outlined,
|
||||
_ => Icons.history_rounded,
|
||||
};
|
||||
final df = DateFormat('dd.MM.yy HH:mm');
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Timeline line + dot
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 24, height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 13, color: color),
|
||||
),
|
||||
if (!isLast)
|
||||
Expanded(child: Container(width: 1.5, color: AppColors.border)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: isLast ? 0 : 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.actionLabel,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
if (item.patientCode != null && item.patientCode!.isNotEmpty)
|
||||
Text(item.patientCode!, style: const TextStyle(fontSize: 11, color: AppColors.accent)),
|
||||
if (item.note != null && item.note!.isNotEmpty)
|
||||
Text(item.note!, style: const TextStyle(fontSize: 11, color: AppColors.textMuted)),
|
||||
const SizedBox(height: 2),
|
||||
Text(df.format(item.createdAt), style: const TextStyle(fontSize: 10, color: AppColors.textMuted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Counterpart Tab ───────────────────────────────────────────────────────────
|
||||
|
||||
class _CounterpartTab extends StatelessWidget {
|
||||
const _CounterpartTab({required this.metrics, required this.label});
|
||||
final ReportMetrics metrics;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stats = metrics.counterpartStats;
|
||||
if (stats.isEmpty) {
|
||||
return Center(
|
||||
child: Text('$label henüz yok.', style: const TextStyle(color: AppColors.textMuted)),
|
||||
);
|
||||
}
|
||||
final fmt = NumberFormat.currency(locale: 'tr_TR', symbol: metrics.currency, decimalDigits: 0);
|
||||
final maxJobs = stats.fold(0, (m, s) => s.jobCount > m ? s.jobCount : m);
|
||||
|
||||
return _TabBody(children: [
|
||||
const _SectionHeader('Protez Türü Dağılımı'),
|
||||
_Card(
|
||||
child: Column(
|
||||
children: _buildTypeRows(metrics.byProstheticType),
|
||||
),
|
||||
),
|
||||
_SectionHeader('En Aktif $label'),
|
||||
_Card(
|
||||
child: Column(
|
||||
children: stats.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final s = entry.value;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: i < stats.length - 1 ? 12 : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22, height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Center(
|
||||
child: Text('${i + 1}',
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.inProgress)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(s.name,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Text('${s.jobCount} iş',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: maxJobs > 0 ? s.jobCount / maxJobs : 0,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.surfaceVariant,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
if (s.totalRevenue > 0) Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'${fmt.format(s.paidRevenue)} tahsil · ${fmt.format(s.pendingRevenue)} bekliyor',
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
List<Widget> _buildTypeRows(Map<String, int> byType) {
|
||||
if (byType.isEmpty) return [const Text('Veri yok', style: TextStyle(color: AppColors.textMuted))];
|
||||
final sorted = byType.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||||
final max = sorted.first.value;
|
||||
return sorted.map((e) => _HBarRow(
|
||||
label: _typeLabel(e.key),
|
||||
value: e.value,
|
||||
max: max,
|
||||
color: AppColors.accent,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
static String _typeLabel(String s) => switch (s) {
|
||||
'metal_porselen' => 'Metal Porselen',
|
||||
'zirkonyum' => 'Zirkonyum',
|
||||
'implant_ustu_zirkonyum'=> 'İmplant Üstü',
|
||||
'gecici' => 'Geçici',
|
||||
'e_max' => 'E-Max',
|
||||
'tam_protez' => 'Tam Protez',
|
||||
'parsiyel' => 'Parsiyel',
|
||||
_ => 'Diğer',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Chart widgets ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _HBarRow extends StatelessWidget {
|
||||
const _HBarRow({required this.label, required this.value, required this.max, required this.color});
|
||||
final String label;
|
||||
final int value, max;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fraction = max > 0 ? value / max : 0.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(label,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 22, color: AppColors.surfaceVariant),
|
||||
FractionallySizedBox(
|
||||
widthFactor: fraction,
|
||||
child: Container(
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text('$value',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: AppColors.textPrimary),
|
||||
textAlign: TextAlign.right),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VBarChart extends StatelessWidget {
|
||||
const _VBarChart({required this.data, required this.color, this.formatValue});
|
||||
final List<MonthlyCount> data;
|
||||
final Color color;
|
||||
final String Function(int)? formatValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (data.isEmpty) return const SizedBox.shrink();
|
||||
final maxVal = data.fold(0, (m, e) => e.count > m ? e.count : m);
|
||||
final fmt = formatValue ?? (v) => '$v';
|
||||
|
||||
return SizedBox(
|
||||
height: 140,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: data.map((d) {
|
||||
final fraction = maxVal > 0 ? d.count / maxVal : 0.0;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (d.count > 0)
|
||||
Text(fmt(d.count),
|
||||
style: const TextStyle(fontSize: 9, color: AppColors.textMuted),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 2),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOut,
|
||||
height: (fraction * 90).clamp(2, 90),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(d.label,
|
||||
style: const TextStyle(fontSize: 10, color: AppColors.textMuted),
|
||||
textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import '../../core/api/pocketbase_client.dart';
|
||||
import '../../models/tenant.dart';
|
||||
import '../../models/user_profile.dart';
|
||||
|
||||
class TeamMember {
|
||||
const TeamMember({
|
||||
required this.memberId,
|
||||
required this.user,
|
||||
required this.role,
|
||||
required this.joinedAt,
|
||||
});
|
||||
final String memberId;
|
||||
final UserProfile user;
|
||||
final TenantRole role;
|
||||
final DateTime joinedAt;
|
||||
}
|
||||
|
||||
class TenantTeamRepository {
|
||||
TenantTeamRepository._();
|
||||
static final instance = TenantTeamRepository._();
|
||||
|
||||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||||
|
||||
Future<List<TeamMember>> listMembers(String tenantId) async {
|
||||
final result = await _pb.collection('tenant_members').getList(
|
||||
filter: 'tenant_id = "$tenantId"',
|
||||
expand: 'user_id',
|
||||
perPage: 200,
|
||||
);
|
||||
return (result.items.map((r) {
|
||||
final j = r.toJson();
|
||||
final userExp = (j['expand'] as Map?)?['user_id'] as Map<String, dynamic>?;
|
||||
return TeamMember(
|
||||
memberId: j['id'] as String,
|
||||
user: UserProfile.fromJson(userExp ?? {'id': j['user_id'], 'email': ''}),
|
||||
role: TenantMembership.parseRole(j['role'] as String),
|
||||
joinedAt: DateTime.parse(j['created'] as String),
|
||||
);
|
||||
}).toList()..sort((a, b) => a.joinedAt.compareTo(b.joinedAt)));
|
||||
}
|
||||
|
||||
Future<TeamMember> addMember({
|
||||
required String tenantId,
|
||||
required String email,
|
||||
required String password,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required TenantRole role,
|
||||
}) async {
|
||||
final userRecord = await _pb.collection('users').create(body: {
|
||||
'email': email.trim().toLowerCase(),
|
||||
'password': password,
|
||||
'passwordConfirm': password,
|
||||
'first_name': firstName.trim(),
|
||||
'last_name': lastName.trim(),
|
||||
'emailVisibility': true,
|
||||
});
|
||||
final memberRecord = await _pb.collection('tenant_members').create(body: {
|
||||
'tenant_id': tenantId,
|
||||
'user_id': userRecord.id,
|
||||
'role': role.value,
|
||||
});
|
||||
final j = memberRecord.toJson();
|
||||
return TeamMember(
|
||||
memberId: j['id'] as String,
|
||||
user: UserProfile.fromJson(userRecord.toJson()),
|
||||
role: role,
|
||||
joinedAt: DateTime.parse(j['created'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> changeMemberRole(String memberId, TenantRole newRole) async {
|
||||
await _pb.collection('tenant_members').update(memberId, body: {
|
||||
'role': newRole.value,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeMember(String memberId) async {
|
||||
await _pb.collection('tenant_members').delete(memberId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/providers/auth_provider.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/tenant.dart';
|
||||
import 'tenant_team_repository.dart';
|
||||
|
||||
class TenantTeamScreen extends ConsumerStatefulWidget {
|
||||
const TenantTeamScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TenantTeamScreen> createState() => _TenantTeamScreenState();
|
||||
}
|
||||
|
||||
class _TenantTeamScreenState extends ConsumerState<TenantTeamScreen> {
|
||||
List<TeamMember> _members = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
final members = await TenantTeamRepository.instance.listMembers(tenantId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_members = members;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canManage =>
|
||||
ref.read(authProvider).activeTenant?.canManageUsers ?? false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Ekip'),
|
||||
actions: [
|
||||
if (_canManage)
|
||||
TextButton.icon(
|
||||
onPressed: () => _showAddMemberSheet(context),
|
||||
icon: const Icon(Icons.person_add_outlined, size: 18),
|
||||
label: const Text('Üye Ekle'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorView(error: _error!, onRetry: _load)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SectionHeader(
|
||||
title: 'Üyeler',
|
||||
count: _members.length,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MembersList(
|
||||
members: _members,
|
||||
canManage: _canManage,
|
||||
currentUserId:
|
||||
ref.read(authProvider).profile?.id ?? '',
|
||||
onRoleChange: _changeRole,
|
||||
onRemove: _removeMember,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddMemberSheet(BuildContext context) async {
|
||||
final tenantId = ref.read(authProvider).activeTenant!.tenant.id;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.background,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => _AddMemberSheet(
|
||||
onAdd: (firstName, lastName, email, password, role) async {
|
||||
await TenantTeamRepository.instance.addMember(
|
||||
tenantId: tenantId,
|
||||
email: email,
|
||||
password: password,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
role: role,
|
||||
);
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _changeRole(TeamMember member, TenantRole newRole) async {
|
||||
try {
|
||||
await TenantTeamRepository.instance.changeMemberRole(
|
||||
member.memberId, newRole);
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeMember(TeamMember member) async {
|
||||
final name = member.user.displayName.isNotEmpty
|
||||
? member.user.displayName
|
||||
: member.user.email;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Üyeyi Çıkar'),
|
||||
content: Text('$name adlı üyeyi ekipten çıkarmak istiyor musunuz?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Vazgeç')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(backgroundColor: AppColors.cancelled),
|
||||
child: const Text('Çıkar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await TenantTeamRepository.instance.removeMember(member.memberId);
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Hata: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section header ─────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.title, required this.count});
|
||||
final String title;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.inProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Members list ───────────────────────────────────────────────────────────
|
||||
|
||||
class _MembersList extends StatelessWidget {
|
||||
const _MembersList({
|
||||
required this.members,
|
||||
required this.canManage,
|
||||
required this.currentUserId,
|
||||
required this.onRoleChange,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final List<TeamMember> members;
|
||||
final bool canManage;
|
||||
final String currentUserId;
|
||||
final Future<void> Function(TeamMember, TenantRole) onRoleChange;
|
||||
final Future<void> Function(TeamMember) onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (members.isEmpty) {
|
||||
return const _EmptyCard(message: 'Henüz üye yok.');
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: members.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final m = entry.value;
|
||||
final isLast = i == members.length - 1;
|
||||
return _MemberTile(
|
||||
member: m,
|
||||
isSelf: m.user.id == currentUserId,
|
||||
canManage: canManage && m.role != TenantRole.owner,
|
||||
showDivider: !isLast,
|
||||
onRoleChange: (role) => onRoleChange(m, role),
|
||||
onRemove: () => onRemove(m),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberTile extends StatelessWidget {
|
||||
const _MemberTile({
|
||||
required this.member,
|
||||
required this.isSelf,
|
||||
required this.canManage,
|
||||
required this.showDivider,
|
||||
required this.onRoleChange,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final TeamMember member;
|
||||
final bool isSelf;
|
||||
final bool canManage;
|
||||
final bool showDivider;
|
||||
final void Function(TenantRole) onRoleChange;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = member.user.displayName.isNotEmpty
|
||||
? member.user.displayName
|
||||
: member.user.email;
|
||||
final initials = name.trim().isNotEmpty
|
||||
? name.trim()[0].toUpperCase()
|
||||
: '?';
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inProgressBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.inProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (isSelf) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'Sen',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
member.user.email,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (canManage) ...[
|
||||
_RoleChip(
|
||||
role: member.role,
|
||||
onChanged: onRoleChange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(Icons.remove_circle_outline,
|
||||
color: AppColors.cancelled, size: 20),
|
||||
tooltip: 'Çıkar',
|
||||
constraints: const BoxConstraints(),
|
||||
padding: const EdgeInsets.all(6),
|
||||
),
|
||||
] else
|
||||
_RoleBadge(role: member.role),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
const Divider(height: 1, indent: 68, color: AppColors.border),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleChip extends StatelessWidget {
|
||||
const _RoleChip({required this.role, required this.onChanged});
|
||||
final TenantRole role;
|
||||
final void Function(TenantRole) onChanged;
|
||||
|
||||
static const _selectableRoles = [
|
||||
TenantRole.admin,
|
||||
TenantRole.technician,
|
||||
TenantRole.delivery,
|
||||
TenantRole.finance,
|
||||
TenantRole.doctor,
|
||||
TenantRole.member,
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton<TenantRole>(
|
||||
initialValue: role,
|
||||
onSelected: onChanged,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
itemBuilder: (_) => _selectableRoles
|
||||
.map((r) => PopupMenuItem(
|
||||
value: r,
|
||||
child: Text(r.label),
|
||||
))
|
||||
.toList(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _roleBg(role),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
role.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _roleColor(role),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.arrow_drop_down, size: 16, color: _roleColor(role)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleBadge extends StatelessWidget {
|
||||
const _RoleBadge({required this.role});
|
||||
final TenantRole role;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: _roleBg(role),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
role.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _roleColor(role),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Color _roleBg(TenantRole r) => switch (r) {
|
||||
TenantRole.owner => AppColors.inProgressBg,
|
||||
TenantRole.admin => AppColors.inProgressBg,
|
||||
TenantRole.doctor => AppColors.successBg,
|
||||
_ => AppColors.surface,
|
||||
};
|
||||
|
||||
Color _roleColor(TenantRole r) => switch (r) {
|
||||
TenantRole.owner => AppColors.inProgress,
|
||||
TenantRole.admin => AppColors.inProgress,
|
||||
TenantRole.doctor => AppColors.success,
|
||||
_ => AppColors.textSecondary,
|
||||
};
|
||||
|
||||
// ── Add member sheet ────────────────────────────────────────────────────────
|
||||
|
||||
class _AddMemberSheet extends StatefulWidget {
|
||||
const _AddMemberSheet({required this.onAdd});
|
||||
final Future<void> Function(
|
||||
String firstName,
|
||||
String lastName,
|
||||
String email,
|
||||
String password,
|
||||
TenantRole role,
|
||||
) onAdd;
|
||||
|
||||
@override
|
||||
State<_AddMemberSheet> createState() => _AddMemberSheetState();
|
||||
}
|
||||
|
||||
class _AddMemberSheetState extends State<_AddMemberSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
TenantRole _selectedRole = TenantRole.member;
|
||||
bool _saving = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
static const _selectableRoles = [
|
||||
TenantRole.admin,
|
||||
TenantRole.technician,
|
||||
TenantRole.delivery,
|
||||
TenantRole.finance,
|
||||
TenantRole.doctor,
|
||||
TenantRole.member,
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await widget.onAdd(
|
||||
_firstNameController.text.trim(),
|
||||
_lastNameController.text.trim(),
|
||||
_emailController.text.trim(),
|
||||
_passwordController.text,
|
||||
_selectedRole,
|
||||
);
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final msg = _friendlyError(e);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
static String _friendlyError(Object e) {
|
||||
final s = e.toString();
|
||||
if (s.contains('email') && s.contains('unique')) {
|
||||
return 'Bu e-posta adresi zaten kayıtlı.';
|
||||
}
|
||||
final msgMatch = RegExp(r'message: ([^,}]+)').firstMatch(s);
|
||||
if (msgMatch != null) return msgMatch.group(1)!.trim();
|
||||
if (s.length > 120) return 'Sunucu hatası';
|
||||
return s;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottom),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Üye Ekle',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _firstNameController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'İsim',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) return 'Zorunlu';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _lastNameController,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Soyisim',
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) return 'Zorunlu';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'E-posta',
|
||||
hintText: 'ornek@email.com',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.trim().isEmpty) return 'E-posta zorunludur';
|
||||
if (!val.contains('@')) return 'Geçerli bir e-posta girin';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Şifre',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined),
|
||||
onPressed: () =>
|
||||
setState(() => _obscurePassword = !_obscurePassword),
|
||||
),
|
||||
),
|
||||
validator: (val) {
|
||||
if (val == null || val.isEmpty) return 'Şifre zorunludur';
|
||||
if (val.length < 8) return 'En az 8 karakter olmalı';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TenantRole>(
|
||||
value: _selectedRole,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Rol',
|
||||
prefixIcon: Icon(Icons.badge_outlined),
|
||||
),
|
||||
items: _selectableRoles
|
||||
.map((r) => DropdownMenuItem(
|
||||
value: r,
|
||||
child: Text(r.label),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v != null) setState(() => _selectedRole = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: _saving ? null : _submit,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.person_add_outlined, size: 18),
|
||||
label: const Text('Üye Ekle'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _EmptyCard extends StatelessWidget {
|
||||
const _EmptyCard({required this.message});
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorView extends StatelessWidget {
|
||||
const _ErrorView({required this.error, required this.onRetry});
|
||||
final String error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: AppColors.cancelled, size: 40),
|
||||
const SizedBox(height: 12),
|
||||
Text(error,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(onPressed: onRetry, child: const Text('Tekrar Dene')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user