Files
lab-app/lib/features/shared/tenant_team_screen.dart
Emre Emir 8bbc9dbff2 Initial commit: DLS - Dental Lab System
- Flutter + PocketBase dental lab management system
- Clinic & lab dashboards, job tracking, patient management
- Product catalog, finance tracking, multi-language support
- AI assistant integration, realtime notifications
- Windows installer (Inno Setup) included
- Developed by kovakyazilim.com
2026-06-11 15:57:31 +03:00

743 lines
23 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers/auth_provider.dart';
import '../../core/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')),
],
),
);
}
}