Initial commit: DLS - Dental Lab System

- Flutter + PocketBase dental lab management system
- Clinic & lab dashboards, job tracking, patient management
- Product catalog, finance tracking, multi-language support
- AI assistant integration, realtime notifications
- Windows installer (Inno Setup) included
- Developed by kovakyazilim.com
This commit is contained in:
Emre Emir
2026-06-11 15:57:31 +03:00
commit 8bbc9dbff2
226 changed files with 31308 additions and 0 deletions
+742
View File
@@ -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')),
],
),
);
}
}