Initial commit
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
final adminUserControllerProvider = Provider<AdminUserController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return AdminUserController(client);
|
||||
});
|
||||
|
||||
class AdminUserStatus {
|
||||
AdminUserStatus({required this.email, required this.bannedUntil});
|
||||
|
||||
final String? email;
|
||||
final DateTime? bannedUntil;
|
||||
|
||||
bool get isLocked {
|
||||
if (bannedUntil == null) return false;
|
||||
return bannedUntil!.isAfter(DateTime.now().toUtc());
|
||||
}
|
||||
}
|
||||
|
||||
class AdminUserController {
|
||||
AdminUserController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> updateProfile({
|
||||
required String userId,
|
||||
required String fullName,
|
||||
required String role,
|
||||
}) async {
|
||||
await _client
|
||||
.from('profiles')
|
||||
.update({'full_name': fullName, 'role': role})
|
||||
.eq('id', userId);
|
||||
}
|
||||
|
||||
Future<void> updateRole({
|
||||
required String userId,
|
||||
required String role,
|
||||
}) async {
|
||||
await _client.from('profiles').update({'role': role}).eq('id', userId);
|
||||
}
|
||||
|
||||
Future<void> setPassword({
|
||||
required String userId,
|
||||
required String password,
|
||||
}) async {
|
||||
await _invokeAdminFunction(
|
||||
action: 'set_password',
|
||||
payload: {'userId': userId, 'password': password},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setLock({required String userId, required bool locked}) async {
|
||||
await _invokeAdminFunction(
|
||||
action: 'set_lock',
|
||||
payload: {'userId': userId, 'locked': locked},
|
||||
);
|
||||
}
|
||||
|
||||
Future<AdminUserStatus> fetchStatus(String userId) async {
|
||||
final data = await _invokeAdminFunction(
|
||||
action: 'get_user',
|
||||
payload: {'userId': userId},
|
||||
);
|
||||
final user = (data as Map<String, dynamic>)['user'] as Map<String, dynamic>;
|
||||
final bannedUntilRaw = user['banned_until'] as String?;
|
||||
return AdminUserStatus(
|
||||
email: user['email'] as String?,
|
||||
bannedUntil: bannedUntilRaw == null
|
||||
? null
|
||||
: DateTime.tryParse(bannedUntilRaw),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _invokeAdminFunction({
|
||||
required String action,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final response = await _client.functions.invoke(
|
||||
'admin_user_management',
|
||||
body: {'action': action, ...payload},
|
||||
);
|
||||
if (response.status != 200) {
|
||||
throw Exception(_extractErrorMessage(response.data));
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
String _extractErrorMessage(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final error = data['error'];
|
||||
if (error is String && error.trim().isNotEmpty) {
|
||||
return error;
|
||||
}
|
||||
final message = data['message'];
|
||||
if (message is String && message.trim().isNotEmpty) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return 'Admin request failed.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
final authStateChangesProvider = StreamProvider<AuthState>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client.auth.onAuthStateChange;
|
||||
});
|
||||
|
||||
final sessionProvider = Provider<Session?>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client.auth.currentSession;
|
||||
});
|
||||
|
||||
final authControllerProvider = Provider<AuthController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return AuthController(client);
|
||||
});
|
||||
|
||||
class AuthController {
|
||||
AuthController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<AuthResponse> signInWithPassword({
|
||||
required String email,
|
||||
required String password,
|
||||
}) {
|
||||
return _client.auth.signInWithPassword(email: email, password: password);
|
||||
}
|
||||
|
||||
Future<AuthResponse> signUp({
|
||||
required String email,
|
||||
required String password,
|
||||
String? fullName,
|
||||
List<String>? officeIds,
|
||||
}) {
|
||||
return _client.auth.signUp(
|
||||
email: email,
|
||||
password: password,
|
||||
data: {
|
||||
if (fullName != null && fullName.trim().isNotEmpty)
|
||||
'full_name': fullName.trim(),
|
||||
if (officeIds != null && officeIds.isNotEmpty) 'office_ids': officeIds,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signInWithGoogle({String? redirectTo}) {
|
||||
return _client.auth.signInWithOAuth(
|
||||
OAuthProvider.google,
|
||||
redirectTo: redirectTo,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signInWithMeta({String? redirectTo}) {
|
||||
return _client.auth.signInWithOAuth(
|
||||
OAuthProvider.facebook,
|
||||
redirectTo: redirectTo,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signOut() {
|
||||
return _client.auth.signOut();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/notification_item.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
if (userId == null) {
|
||||
return const Stream.empty();
|
||||
}
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('notifications')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', ascending: false)
|
||||
.map((rows) => rows.map(NotificationItem.fromMap).toList());
|
||||
});
|
||||
|
||||
final unreadNotificationsCountProvider = Provider<int>((ref) {
|
||||
final notificationsAsync = ref.watch(notificationsProvider);
|
||||
return notificationsAsync.maybeWhen(
|
||||
data: (items) => items.where((item) => item.isUnread).length,
|
||||
orElse: () => 0,
|
||||
);
|
||||
});
|
||||
|
||||
final notificationsControllerProvider = Provider<NotificationsController>((
|
||||
ref,
|
||||
) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return NotificationsController(client);
|
||||
});
|
||||
|
||||
class NotificationsController {
|
||||
NotificationsController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> createMentionNotifications({
|
||||
required List<String> userIds,
|
||||
required String actorId,
|
||||
required int messageId,
|
||||
String? ticketId,
|
||||
String? taskId,
|
||||
}) async {
|
||||
if (userIds.isEmpty) return;
|
||||
if ((ticketId == null || ticketId.isEmpty) &&
|
||||
(taskId == null || taskId.isEmpty)) {
|
||||
return;
|
||||
}
|
||||
final rows = userIds
|
||||
.map(
|
||||
(userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': actorId,
|
||||
'ticket_id': ticketId,
|
||||
'task_id': taskId,
|
||||
'message_id': messageId,
|
||||
'type': 'mention',
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
await _client.from('notifications').insert(rows);
|
||||
}
|
||||
|
||||
Future<void> markRead(String id) async {
|
||||
await _client
|
||||
.from('notifications')
|
||||
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
|
||||
.eq('id', id);
|
||||
}
|
||||
|
||||
Future<void> markReadForTicket(String ticketId) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
await _client
|
||||
.from('notifications')
|
||||
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
|
||||
.eq('ticket_id', ticketId)
|
||||
.eq('user_id', userId)
|
||||
.filter('read_at', 'is', null);
|
||||
}
|
||||
|
||||
Future<void> markReadForTask(String taskId) async {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null) return;
|
||||
await _client
|
||||
.from('notifications')
|
||||
.update({'read_at': DateTime.now().toUtc().toIso8601String()})
|
||||
.eq('task_id', taskId)
|
||||
.eq('user_id', userId)
|
||||
.filter('read_at', 'is', null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/profile.dart';
|
||||
import 'auth_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
final currentUserIdProvider = Provider<String?>((ref) {
|
||||
final authState = ref.watch(authStateChangesProvider);
|
||||
return authState.maybeWhen(
|
||||
data: (state) => state.session?.user.id,
|
||||
orElse: () => ref.watch(sessionProvider)?.user.id,
|
||||
);
|
||||
});
|
||||
|
||||
final currentProfileProvider = StreamProvider<Profile?>((ref) {
|
||||
final userId = ref.watch(currentUserIdProvider);
|
||||
if (userId == null) {
|
||||
return const Stream.empty();
|
||||
}
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('profiles')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('id', userId)
|
||||
.map((rows) => rows.isEmpty ? null : Profile.fromMap(rows.first));
|
||||
});
|
||||
|
||||
final profilesProvider = StreamProvider<List<Profile>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('profiles')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('full_name')
|
||||
.map((rows) => rows.map(Profile.fromMap).toList());
|
||||
});
|
||||
|
||||
final isAdminProvider = Provider<bool>((ref) {
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
return profileAsync.maybeWhen(
|
||||
data: (profile) => profile?.role == 'admin',
|
||||
orElse: () => false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
final supabaseClientProvider = Provider<SupabaseClient>((ref) {
|
||||
return Supabase.instance.client;
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/task.dart';
|
||||
import '../models/task_assignment.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'tickets_provider.dart';
|
||||
import 'user_offices_provider.dart';
|
||||
|
||||
final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final ticketsAsync = ref.watch(ticketsProvider);
|
||||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||||
|
||||
final profile = profileAsync.valueOrNull;
|
||||
if (profile == null) {
|
||||
return Stream.value(const <Task>[]);
|
||||
}
|
||||
|
||||
final isGlobal =
|
||||
profile.role == 'admin' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff';
|
||||
|
||||
if (isGlobal) {
|
||||
return client
|
||||
.from('tasks')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('queue_order', ascending: true)
|
||||
.order('created_at')
|
||||
.map((rows) => rows.map(Task.fromMap).toList());
|
||||
}
|
||||
|
||||
final allowedTicketIds =
|
||||
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
|
||||
<String>[];
|
||||
final officeIds =
|
||||
assignmentsAsync.valueOrNull
|
||||
?.where((assignment) => assignment.userId == profile.id)
|
||||
.map((assignment) => assignment.officeId)
|
||||
.toSet()
|
||||
.toList() ??
|
||||
<String>[];
|
||||
|
||||
if (allowedTicketIds.isEmpty && officeIds.isEmpty) {
|
||||
return Stream.value(const <Task>[]);
|
||||
}
|
||||
|
||||
return client
|
||||
.from('tasks')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('queue_order', ascending: true)
|
||||
.order('created_at')
|
||||
.map(
|
||||
(rows) => rows.map(Task.fromMap).where((task) {
|
||||
final matchesTicket =
|
||||
task.ticketId != null && allowedTicketIds.contains(task.ticketId);
|
||||
final matchesOffice =
|
||||
task.officeId != null && officeIds.contains(task.officeId);
|
||||
return matchesTicket || matchesOffice;
|
||||
}).toList(),
|
||||
);
|
||||
});
|
||||
|
||||
final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('task_assignments')
|
||||
.stream(primaryKey: ['task_id', 'user_id'])
|
||||
.map((rows) => rows.map(TaskAssignment.fromMap).toList());
|
||||
});
|
||||
|
||||
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
|
||||
ref,
|
||||
) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return TaskAssignmentsController(client);
|
||||
});
|
||||
|
||||
final tasksControllerProvider = Provider<TasksController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return TasksController(client);
|
||||
});
|
||||
|
||||
class TasksController {
|
||||
TasksController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> updateTaskStatus({
|
||||
required String taskId,
|
||||
required String status,
|
||||
}) async {
|
||||
await _client.from('tasks').update({'status': status}).eq('id', taskId);
|
||||
}
|
||||
|
||||
Future<void> createTask({
|
||||
required String title,
|
||||
required String description,
|
||||
required String officeId,
|
||||
}) async {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
final data = await _client
|
||||
.from('tasks')
|
||||
.insert({
|
||||
'title': title,
|
||||
'description': description,
|
||||
'office_id': officeId,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
final taskId = data['id'] as String?;
|
||||
if (taskId == null) return;
|
||||
unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
|
||||
}
|
||||
|
||||
Future<void> _notifyCreated({
|
||||
required String taskId,
|
||||
required String? actorId,
|
||||
}) async {
|
||||
try {
|
||||
final recipients = await _fetchRoleUserIds(
|
||||
roles: const ['dispatcher', 'it_staff'],
|
||||
excludeUserId: actorId,
|
||||
);
|
||||
if (recipients.isEmpty) return;
|
||||
final rows = recipients
|
||||
.map(
|
||||
(userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': actorId,
|
||||
'task_id': taskId,
|
||||
'type': 'created',
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
await _client.from('notifications').insert(rows);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _fetchRoleUserIds({
|
||||
required List<String> roles,
|
||||
required String? excludeUserId,
|
||||
}) async {
|
||||
try {
|
||||
final data = await _client
|
||||
.from('profiles')
|
||||
.select('id, role')
|
||||
.inFilter('role', roles);
|
||||
final rows = data as List<dynamic>;
|
||||
final ids = rows
|
||||
.map((row) => row['id'] as String?)
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty && id != excludeUserId)
|
||||
.toList();
|
||||
return ids;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TaskAssignmentsController {
|
||||
TaskAssignmentsController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> replaceAssignments({
|
||||
required String taskId,
|
||||
required String? ticketId,
|
||||
required List<String> newUserIds,
|
||||
required List<String> currentUserIds,
|
||||
}) async {
|
||||
final nextIds = newUserIds.toSet();
|
||||
final currentIds = currentUserIds.toSet();
|
||||
final toAdd = nextIds.difference(currentIds).toList();
|
||||
final toRemove = currentIds.difference(nextIds).toList();
|
||||
|
||||
if (toAdd.isNotEmpty) {
|
||||
final rows = toAdd
|
||||
.map((userId) => {'task_id': taskId, 'user_id': userId})
|
||||
.toList();
|
||||
await _client.from('task_assignments').insert(rows);
|
||||
await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd);
|
||||
}
|
||||
if (toRemove.isNotEmpty) {
|
||||
await _client
|
||||
.from('task_assignments')
|
||||
.delete()
|
||||
.eq('task_id', taskId)
|
||||
.inFilter('user_id', toRemove);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _notifyAssigned({
|
||||
required String taskId,
|
||||
required String? ticketId,
|
||||
required List<String> userIds,
|
||||
}) async {
|
||||
if (userIds.isEmpty) return;
|
||||
try {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
final rows = userIds
|
||||
.map(
|
||||
(userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': actorId,
|
||||
'task_id': taskId,
|
||||
'ticket_id': ticketId,
|
||||
'type': 'assignment',
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
await _client.from('notifications').insert(rows);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeAssignment({
|
||||
required String taskId,
|
||||
required String userId,
|
||||
}) async {
|
||||
await _client
|
||||
.from('task_assignments')
|
||||
.delete()
|
||||
.eq('task_id', taskId)
|
||||
.eq('user_id', userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/office.dart';
|
||||
import '../models/ticket.dart';
|
||||
import '../models/ticket_message.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'supabase_provider.dart';
|
||||
import 'user_offices_provider.dart';
|
||||
|
||||
final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('offices')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('name')
|
||||
.map((rows) => rows.map(Office.fromMap).toList());
|
||||
});
|
||||
|
||||
final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final rows = await client.from('offices').select().order('name');
|
||||
return (rows as List<dynamic>)
|
||||
.map((row) => Office.fromMap(row as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final officesControllerProvider = Provider<OfficesController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return OfficesController(client);
|
||||
});
|
||||
|
||||
final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||||
|
||||
final profile = profileAsync.valueOrNull;
|
||||
if (profile == null) {
|
||||
return Stream.value(const <Ticket>[]);
|
||||
}
|
||||
|
||||
final isGlobal =
|
||||
profile.role == 'admin' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff';
|
||||
|
||||
if (isGlobal) {
|
||||
return client
|
||||
.from('tickets')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('created_at', ascending: false)
|
||||
.map((rows) => rows.map(Ticket.fromMap).toList());
|
||||
}
|
||||
|
||||
final officeIds =
|
||||
assignmentsAsync.valueOrNull
|
||||
?.where((assignment) => assignment.userId == profile.id)
|
||||
.map((assignment) => assignment.officeId)
|
||||
.toSet()
|
||||
.toList() ??
|
||||
<String>[];
|
||||
if (officeIds.isEmpty) {
|
||||
return Stream.value(const <Ticket>[]);
|
||||
}
|
||||
|
||||
return client
|
||||
.from('tickets')
|
||||
.stream(primaryKey: ['id'])
|
||||
.inFilter('office_id', officeIds)
|
||||
.order('created_at', ascending: false)
|
||||
.map((rows) => rows.map(Ticket.fromMap).toList());
|
||||
});
|
||||
|
||||
final ticketMessagesProvider =
|
||||
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('ticket_messages')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('ticket_id', ticketId)
|
||||
.order('created_at', ascending: false)
|
||||
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
||||
});
|
||||
|
||||
final ticketMessagesAllProvider = StreamProvider<List<TicketMessage>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('ticket_messages')
|
||||
.stream(primaryKey: ['id'])
|
||||
.order('created_at', ascending: false)
|
||||
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
||||
});
|
||||
|
||||
final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
|
||||
(ref, taskId) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('ticket_messages')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('task_id', taskId)
|
||||
.order('created_at', ascending: false)
|
||||
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
||||
},
|
||||
);
|
||||
|
||||
final ticketsControllerProvider = Provider<TicketsController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return TicketsController(client);
|
||||
});
|
||||
|
||||
class TicketsController {
|
||||
TicketsController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> createTicket({
|
||||
required String subject,
|
||||
required String description,
|
||||
required String officeId,
|
||||
}) async {
|
||||
final actorId = _client.auth.currentUser?.id;
|
||||
final data = await _client
|
||||
.from('tickets')
|
||||
.insert({
|
||||
'subject': subject,
|
||||
'description': description,
|
||||
'office_id': officeId,
|
||||
'creator_id': _client.auth.currentUser?.id,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
final ticketId = data['id'] as String?;
|
||||
if (ticketId == null) return;
|
||||
unawaited(_notifyCreated(ticketId: ticketId, actorId: actorId));
|
||||
}
|
||||
|
||||
Future<void> _notifyCreated({
|
||||
required String ticketId,
|
||||
required String? actorId,
|
||||
}) async {
|
||||
try {
|
||||
final recipients = await _fetchRoleUserIds(
|
||||
roles: const ['dispatcher', 'it_staff'],
|
||||
excludeUserId: actorId,
|
||||
);
|
||||
if (recipients.isEmpty) return;
|
||||
final rows = recipients
|
||||
.map(
|
||||
(userId) => {
|
||||
'user_id': userId,
|
||||
'actor_id': actorId,
|
||||
'ticket_id': ticketId,
|
||||
'type': 'created',
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
await _client.from('notifications').insert(rows);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> _fetchRoleUserIds({
|
||||
required List<String> roles,
|
||||
required String? excludeUserId,
|
||||
}) async {
|
||||
try {
|
||||
final data = await _client
|
||||
.from('profiles')
|
||||
.select('id, role')
|
||||
.inFilter('role', roles);
|
||||
final rows = data as List<dynamic>;
|
||||
final ids = rows
|
||||
.map((row) => row['id'] as String?)
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty && id != excludeUserId)
|
||||
.toList();
|
||||
return ids;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<TicketMessage> sendTicketMessage({
|
||||
required String ticketId,
|
||||
required String content,
|
||||
}) async {
|
||||
final data = await _client
|
||||
.from('ticket_messages')
|
||||
.insert({
|
||||
'ticket_id': ticketId,
|
||||
'content': content,
|
||||
'sender_id': _client.auth.currentUser?.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
return TicketMessage.fromMap(data);
|
||||
}
|
||||
|
||||
Future<TicketMessage> sendTaskMessage({
|
||||
required String taskId,
|
||||
required String? ticketId,
|
||||
required String content,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{
|
||||
'task_id': taskId,
|
||||
'content': content,
|
||||
'sender_id': _client.auth.currentUser?.id,
|
||||
};
|
||||
if (ticketId != null) {
|
||||
payload['ticket_id'] = ticketId;
|
||||
}
|
||||
final data = await _client
|
||||
.from('ticket_messages')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single();
|
||||
return TicketMessage.fromMap(data);
|
||||
}
|
||||
|
||||
Future<void> updateTicketStatus({
|
||||
required String ticketId,
|
||||
required String status,
|
||||
}) async {
|
||||
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
|
||||
}
|
||||
}
|
||||
|
||||
class OfficesController {
|
||||
OfficesController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> createOffice({required String name}) async {
|
||||
await _client.from('offices').insert({'name': name});
|
||||
}
|
||||
|
||||
Future<void> updateOffice({required String id, required String name}) async {
|
||||
await _client.from('offices').update({'name': name}).eq('id', id);
|
||||
}
|
||||
|
||||
Future<void> deleteOffice({required String id}) async {
|
||||
await _client.from('offices').delete().eq('id', id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
class TypingIndicatorState {
|
||||
const TypingIndicatorState({
|
||||
required this.userIds,
|
||||
required this.channelStatus,
|
||||
required this.lastPayload,
|
||||
});
|
||||
|
||||
final Set<String> userIds;
|
||||
final String channelStatus;
|
||||
final Map<String, dynamic> lastPayload;
|
||||
|
||||
TypingIndicatorState copyWith({
|
||||
Set<String>? userIds,
|
||||
String? channelStatus,
|
||||
Map<String, dynamic>? lastPayload,
|
||||
}) {
|
||||
return TypingIndicatorState(
|
||||
userIds: userIds ?? this.userIds,
|
||||
channelStatus: channelStatus ?? this.channelStatus,
|
||||
lastPayload: lastPayload ?? this.lastPayload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final typingIndicatorProvider = StateNotifierProvider.autoDispose
|
||||
.family<TypingIndicatorController, TypingIndicatorState, String>((
|
||||
ref,
|
||||
ticketId,
|
||||
) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final controller = TypingIndicatorController(client, ticketId);
|
||||
ref.onDispose(controller.dispose);
|
||||
return controller;
|
||||
});
|
||||
|
||||
class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||
TypingIndicatorController(this._client, this._ticketId)
|
||||
: super(
|
||||
const TypingIndicatorState(
|
||||
userIds: {},
|
||||
channelStatus: 'init',
|
||||
lastPayload: {},
|
||||
),
|
||||
) {
|
||||
_initChannel();
|
||||
}
|
||||
|
||||
final SupabaseClient _client;
|
||||
final String _ticketId;
|
||||
RealtimeChannel? _channel;
|
||||
Timer? _typingTimer;
|
||||
final Map<String, Timer> _remoteTimeouts = {};
|
||||
|
||||
void _initChannel() {
|
||||
final channel = _client.channel('typing:$_ticketId');
|
||||
channel.onBroadcast(
|
||||
event: 'typing',
|
||||
callback: (payload) {
|
||||
final Map<String, dynamic> data = _extractPayload(payload);
|
||||
final userId = data['user_id'] as String?;
|
||||
final rawType = data['type']?.toString();
|
||||
final currentUserId = _client.auth.currentUser?.id;
|
||||
state = state.copyWith(lastPayload: data);
|
||||
if (userId == null || userId == currentUserId) {
|
||||
return;
|
||||
}
|
||||
if (rawType == 'stop') {
|
||||
_clearRemoteTyping(userId);
|
||||
return;
|
||||
}
|
||||
_markRemoteTyping(userId);
|
||||
},
|
||||
);
|
||||
channel.subscribe((status, error) {
|
||||
state = state.copyWith(channelStatus: status.name);
|
||||
});
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _extractPayload(dynamic payload) {
|
||||
if (payload is Map<String, dynamic>) {
|
||||
final inner = payload['payload'];
|
||||
if (inner is Map<String, dynamic>) {
|
||||
return inner;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
final dynamic inner = payload.payload;
|
||||
if (inner is Map<String, dynamic>) {
|
||||
return inner;
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
void userTyping() {
|
||||
if (_client.auth.currentUser?.id == null) return;
|
||||
_sendTypingEvent('start');
|
||||
_typingTimer?.cancel();
|
||||
_typingTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
_sendTypingEvent('stop');
|
||||
});
|
||||
}
|
||||
|
||||
void stopTyping() {
|
||||
_typingTimer?.cancel();
|
||||
_sendTypingEvent('stop');
|
||||
}
|
||||
|
||||
void _markRemoteTyping(String userId) {
|
||||
final updated = {...state.userIds, userId};
|
||||
state = state.copyWith(userIds: updated);
|
||||
_remoteTimeouts[userId]?.cancel();
|
||||
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
|
||||
_clearRemoteTyping(userId);
|
||||
});
|
||||
}
|
||||
|
||||
void _clearRemoteTyping(String userId) {
|
||||
final updated = {...state.userIds}..remove(userId);
|
||||
state = state.copyWith(userIds: updated);
|
||||
_remoteTimeouts[userId]?.cancel();
|
||||
_remoteTimeouts.remove(userId);
|
||||
}
|
||||
|
||||
void _sendTypingEvent(String type) {
|
||||
final userId = _client.auth.currentUser?.id;
|
||||
if (userId == null || _channel == null) return;
|
||||
_channel!.sendBroadcastMessage(
|
||||
event: 'typing',
|
||||
payload: {'user_id': userId, 'type': type},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
stopTyping();
|
||||
_typingTimer?.cancel();
|
||||
for (final timer in _remoteTimeouts.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_remoteTimeouts.clear();
|
||||
_channel?.unsubscribe();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/user_office.dart';
|
||||
import 'supabase_provider.dart';
|
||||
|
||||
final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return client
|
||||
.from('user_offices')
|
||||
.stream(primaryKey: ['user_id', 'office_id'])
|
||||
.order('created_at')
|
||||
.map((rows) => rows.map(UserOffice.fromMap).toList());
|
||||
});
|
||||
|
||||
final userOfficesControllerProvider = Provider<UserOfficesController>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
return UserOfficesController(client);
|
||||
});
|
||||
|
||||
class UserOfficesController {
|
||||
UserOfficesController(this._client);
|
||||
|
||||
final SupabaseClient _client;
|
||||
|
||||
Future<void> assignUserOffice({
|
||||
required String userId,
|
||||
required String officeId,
|
||||
}) async {
|
||||
await _client.from('user_offices').insert({
|
||||
'user_id': userId,
|
||||
'office_id': officeId,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeUserOffice({
|
||||
required String userId,
|
||||
required String officeId,
|
||||
}) async {
|
||||
await _client
|
||||
.from('user_offices')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.eq('office_id', officeId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user