743 lines
23 KiB
Dart
743 lines
23 KiB
Dart
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')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|