8bbc9dbff2
- 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
686 lines
22 KiB
Dart
686 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import '../../../core/l10n/app_strings.dart';
|
||
import '../../../core/providers/auth_provider.dart';
|
||
import '../../../core/providers/locale_provider.dart';
|
||
import '../../../core/router/app_router.dart';
|
||
import '../../../core/theme/app_theme.dart';
|
||
import '../../../models/tenant.dart';
|
||
import '../../shared/tenant_team_screen.dart';
|
||
import '../connections/clinic_connections_screen.dart';
|
||
|
||
class ClinicSettingsScreen extends ConsumerWidget {
|
||
const ClinicSettingsScreen({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final auth = ref.watch(authProvider);
|
||
final s = ref.watch(stringsProvider);
|
||
final profile = auth.profile;
|
||
final membership = auth.activeTenant;
|
||
final tenant = membership?.tenant;
|
||
final canEdit = membership?.isAdmin ?? false;
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: Text(s.settings)),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
// User card
|
||
_SectionHeader(title: s.userInfo),
|
||
_UserCard(profile: profile),
|
||
const SizedBox(height: 20),
|
||
|
||
// Clinic info
|
||
_SectionHeader(
|
||
title: s.clinicInfo,
|
||
action: canEdit
|
||
? IconButton(
|
||
icon: const Icon(Icons.edit_outlined,
|
||
size: 18, color: AppColors.accent),
|
||
tooltip: s.edit,
|
||
onPressed: () => _showEditSheet(context, ref, tenant, s),
|
||
)
|
||
: null,
|
||
),
|
||
_InfoCard(children: [
|
||
_InfoTile(
|
||
icon: Icons.business,
|
||
label: s.clinicName,
|
||
value: tenant?.companyName ?? '-',
|
||
),
|
||
_InfoTile(
|
||
icon: Icons.category_outlined,
|
||
label: s.type,
|
||
value: _tenantKindLabel(tenant?.kind, s),
|
||
),
|
||
_InfoTile(
|
||
icon: Icons.star_outline,
|
||
label: s.role,
|
||
value: _roleLabel(membership?.role, s),
|
||
),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
|
||
// Connections
|
||
if (membership?.showConnections ?? false) ...[
|
||
_SectionHeader(title: s.connections),
|
||
_InfoCard(children: [
|
||
_NavTile(
|
||
icon: Icons.link_rounded,
|
||
iconColor: AppColors.inProgress,
|
||
iconBg: AppColors.inProgressBg,
|
||
title: s.labConnections,
|
||
subtitle: s.labConnectionsSub,
|
||
onTap: () => Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => const ClinicConnectionsScreen()),
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
],
|
||
|
||
// Other memberships
|
||
if (auth.memberships.length > 1) ...[
|
||
_SectionHeader(title: s.otherMemberships),
|
||
_InfoCard(children: [
|
||
for (final m
|
||
in auth.memberships.where((m) => m.id != membership?.id))
|
||
_NavTile(
|
||
icon: Icons.switch_account_outlined,
|
||
iconColor: AppColors.inProgress,
|
||
iconBg: AppColors.inProgressBg,
|
||
title: m.tenant.companyName,
|
||
subtitle: _tenantKindLabel(m.tenant.kind, s),
|
||
onTap: () {
|
||
ref.read(authProvider.notifier).setActiveTenant(m);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(s.tenantSelected(m.tenant.companyName))),
|
||
);
|
||
},
|
||
),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
],
|
||
|
||
// Team management + Reports
|
||
if (membership?.canManageUsers ?? false) ...[
|
||
_SectionHeader(title: s.management),
|
||
_InfoCard(children: [
|
||
_NavTile(
|
||
icon: Icons.group_outlined,
|
||
iconColor: AppColors.inProgress,
|
||
iconBg: AppColors.inProgressBg,
|
||
title: s.team,
|
||
subtitle: s.teamSub,
|
||
onTap: () => Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => const TenantTeamScreen()),
|
||
),
|
||
),
|
||
_NavTile(
|
||
icon: Icons.bar_chart_rounded,
|
||
iconColor: AppColors.accent,
|
||
iconBg: AppColors.inProgressBg,
|
||
title: s.reports,
|
||
subtitle: s.reportsSub,
|
||
onTap: () => context.push(routeClinicReports),
|
||
),
|
||
_NavTile(
|
||
icon: Icons.auto_awesome_outlined,
|
||
iconColor: const Color(0xFF7C3AED),
|
||
iconBg: const Color(0xFFF3E8FF),
|
||
title: s.aiAssistant,
|
||
subtitle: s.aiAssistantSub,
|
||
onTap: () => context.push(routeClinicAi),
|
||
),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
],
|
||
|
||
// Preferences (language)
|
||
_SectionHeader(title: s.preferences),
|
||
_InfoCard(children: [
|
||
_NavTile(
|
||
icon: Icons.language_outlined,
|
||
iconColor: AppColors.accent,
|
||
iconBg: AppColors.inProgressBg,
|
||
title: s.appLanguage,
|
||
subtitle: _currentLanguageLabel(ref.watch(localeProvider).languageCode, s),
|
||
onTap: () => _showLanguagePicker(context, ref, s),
|
||
),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
|
||
// Sign out
|
||
_SignOutCard(ref: ref, s: s),
|
||
const SizedBox(height: 32),
|
||
const Center(
|
||
child: Text('DLS — Dental Lab System',
|
||
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||
),
|
||
const SizedBox(height: 4),
|
||
const Center(
|
||
child: Text('Geliştirici: kovakyazilim.com',
|
||
style: TextStyle(fontSize: 12, color: AppColors.textMuted)),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showEditSheet(BuildContext context, WidgetRef ref, Tenant? tenant, AppStrings s) {
|
||
if (tenant == null) return;
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => _EditTenantSheet(
|
||
tenant: tenant,
|
||
s: s,
|
||
onSave: (name) async {
|
||
await ref
|
||
.read(authProvider.notifier)
|
||
.updateTenantInfo(tenantId: tenant.id, companyName: name);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showLanguagePicker(BuildContext context, WidgetRef ref, AppStrings s) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (_) => _LanguagePickerSheet(s: s, ref: ref),
|
||
);
|
||
}
|
||
|
||
static String _currentLanguageLabel(String code, AppStrings s) => switch (code) {
|
||
'en' => s.languageEnglish,
|
||
'ru' => s.languageRussian,
|
||
'ar' => s.languageArabic,
|
||
'de' => s.languageGerman,
|
||
_ => s.languageTurkish,
|
||
};
|
||
|
||
static String _tenantKindLabel(TenantKind? kind, AppStrings s) =>
|
||
switch (kind) {
|
||
TenantKind.clinic => s.tenantKindClinic,
|
||
TenantKind.lab => s.tenantKindLab,
|
||
null => '-',
|
||
};
|
||
|
||
static String _roleLabel(TenantRole? role, AppStrings s) => switch (role) {
|
||
TenantRole.owner => s.roleOwner,
|
||
TenantRole.admin => s.roleAdmin,
|
||
TenantRole.technician => s.roleTechnician,
|
||
TenantRole.delivery => s.roleDelivery,
|
||
TenantRole.finance => s.roleFinance,
|
||
TenantRole.doctor => s.roleDoctor,
|
||
TenantRole.member => s.roleMember,
|
||
null => '-',
|
||
};
|
||
}
|
||
|
||
// ── Language picker sheet ─────────────────────────────────────────────────────
|
||
|
||
class _LanguagePickerSheet extends ConsumerWidget {
|
||
const _LanguagePickerSheet({required this.s, required this.ref});
|
||
final AppStrings s;
|
||
final WidgetRef ref;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef _) {
|
||
final currentLocale = ref.watch(localeProvider);
|
||
final options = [
|
||
('tr', '🇹🇷', s.languageTurkish),
|
||
('en', '🇬🇧', s.languageEnglish),
|
||
('ru', '🇷🇺', s.languageRussian),
|
||
('ar', '🇸🇦', s.languageArabic),
|
||
('de', '🇩🇪', s.languageGerman),
|
||
];
|
||
|
||
return Container(
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.border,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
s.languageSelection,
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
for (final (code, flag, label) in options)
|
||
ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||
leading: Text(flag, style: const TextStyle(fontSize: 24)),
|
||
title: Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
trailing: currentLocale.languageCode == code
|
||
? const Icon(Icons.check_circle_rounded,
|
||
color: AppColors.accent)
|
||
: null,
|
||
onTap: () {
|
||
ref.read(localeProvider.notifier).setLocale(Locale(code));
|
||
ref.read(authProvider.notifier).updateLanguage(code);
|
||
Navigator.pop(context);
|
||
},
|
||
),
|
||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Edit sheet ────────────────────────────────────────────────────────────────
|
||
|
||
class _EditTenantSheet extends StatefulWidget {
|
||
const _EditTenantSheet({
|
||
required this.tenant,
|
||
required this.s,
|
||
required this.onSave,
|
||
});
|
||
final Tenant tenant;
|
||
final AppStrings s;
|
||
final Future<void> Function(String companyName) onSave;
|
||
|
||
@override
|
||
State<_EditTenantSheet> createState() => _EditTenantSheetState();
|
||
}
|
||
|
||
class _EditTenantSheetState extends State<_EditTenantSheet> {
|
||
late final TextEditingController _nameController;
|
||
bool _saving = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_nameController = TextEditingController(text: widget.tenant.companyName);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_nameController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
final name = _nameController.text.trim();
|
||
if (name.isEmpty) return;
|
||
setState(() => _saving = true);
|
||
final navigator = Navigator.of(context);
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
try {
|
||
await widget.onSave(name);
|
||
navigator.pop();
|
||
} catch (e) {
|
||
messenger.showSnackBar(
|
||
SnackBar(content: Text('${widget.s.errorPrefix}: $e')));
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final s = widget.s;
|
||
return Padding(
|
||
padding:
|
||
EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Center(
|
||
child: Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.border,
|
||
borderRadius: BorderRadius.circular(2)),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(s.editClinicInfo,
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary)),
|
||
const SizedBox(height: 16),
|
||
TextFormField(
|
||
controller: _nameController,
|
||
decoration: InputDecoration(
|
||
labelText: s.clinicName,
|
||
hintText: s.clinicNameHint,
|
||
),
|
||
textCapitalization: TextCapitalization.words,
|
||
),
|
||
const SizedBox(height: 20),
|
||
if (_saving)
|
||
const Center(
|
||
child: CircularProgressIndicator(color: AppColors.accent))
|
||
else
|
||
FilledButton(
|
||
onPressed: _submit,
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size(double.infinity, 48)),
|
||
child: Text(s.save),
|
||
),
|
||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 4),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Reusable UI pieces ────────────────────────────────────────────────────────
|
||
|
||
class _UserCard extends StatelessWidget {
|
||
const _UserCard({required this.profile});
|
||
final dynamic profile;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final displayName = (profile?.displayName?.isNotEmpty == true)
|
||
? profile!.displayName as String
|
||
: 'Kullanıcı';
|
||
final initial = (profile?.displayName?.isNotEmpty == true
|
||
? (profile!.displayName as String)[0]
|
||
: (profile?.email as String?)?[0] ?? '?')
|
||
.toUpperCase();
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.05),
|
||
blurRadius: 12,
|
||
offset: const Offset(0, 4))
|
||
],
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 56,
|
||
height: 56,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.inProgressBg,
|
||
borderRadius: BorderRadius.circular(14)),
|
||
child: Center(
|
||
child: Text(initial,
|
||
style: const TextStyle(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.inProgress)),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(displayName,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary)),
|
||
const SizedBox(height: 2),
|
||
Text(profile?.email as String? ?? '',
|
||
style: const TextStyle(
|
||
fontSize: 13, color: AppColors.textSecondary)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SectionHeader extends StatelessWidget {
|
||
const _SectionHeader({required this.title, this.action});
|
||
final String title;
|
||
final Widget? action;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.accent,
|
||
letterSpacing: 0.3),
|
||
),
|
||
),
|
||
if (action != null) action!,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InfoCard extends StatelessWidget {
|
||
const _InfoCard({required this.children});
|
||
final List<Widget> children;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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: [
|
||
for (int i = 0; i < children.length; i++) ...[
|
||
children[i],
|
||
if (i < children.length - 1)
|
||
const Divider(
|
||
height: 1, indent: 16, endIndent: 16, color: AppColors.border),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InfoTile extends StatelessWidget {
|
||
const _InfoTile(
|
||
{required this.icon, required this.label, required this.value});
|
||
final IconData icon;
|
||
final String label;
|
||
final String value;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, size: 18, color: AppColors.textSecondary),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label,
|
||
style: const TextStyle(
|
||
fontSize: 11, color: AppColors.textMuted)),
|
||
const SizedBox(height: 2),
|
||
Text(value,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.textPrimary)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _NavTile extends StatelessWidget {
|
||
const _NavTile({
|
||
required this.icon,
|
||
required this.iconColor,
|
||
required this.iconBg,
|
||
required this.title,
|
||
required this.onTap,
|
||
this.subtitle,
|
||
});
|
||
|
||
final IconData icon;
|
||
final Color iconColor;
|
||
final Color iconBg;
|
||
final String title;
|
||
final String? subtitle;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListTile(
|
||
contentPadding:
|
||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||
leading: Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: iconBg, borderRadius: BorderRadius.circular(9)),
|
||
child: Icon(icon, color: iconColor, size: 18),
|
||
),
|
||
title: Text(title,
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||
subtitle: subtitle != null
|
||
? Text(subtitle!,
|
||
style: const TextStyle(color: AppColors.textSecondary))
|
||
: null,
|
||
trailing:
|
||
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||
onTap: onTap,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SignOutCard extends StatelessWidget {
|
||
const _SignOutCard({required this.ref, required this.s});
|
||
final WidgetRef ref;
|
||
final AppStrings s;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: AppColors.surface,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.cancelledBg),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.05),
|
||
blurRadius: 12,
|
||
offset: const Offset(0, 4))
|
||
],
|
||
),
|
||
child: ListTile(
|
||
contentPadding:
|
||
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||
leading: Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cancelledBg,
|
||
borderRadius: BorderRadius.circular(9)),
|
||
child: const Icon(Icons.logout,
|
||
color: AppColors.cancelled, size: 18),
|
||
),
|
||
title: Text(s.signOut,
|
||
style: const TextStyle(
|
||
color: AppColors.cancelled, fontWeight: FontWeight.w600)),
|
||
onTap: () async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text(s.signOutTitle),
|
||
content: Text(s.signOutConfirm),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text(s.cancel)),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: AppColors.cancelled),
|
||
child: Text(s.signOut),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed == true) {
|
||
await ref.read(authProvider.notifier).signOut();
|
||
}
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|