Pagination
This commit is contained in:
parent
1c73595d07
commit
5f666ed6ea
|
|
@ -4,6 +4,39 @@ import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'supabase_provider.dart';
|
import 'supabase_provider.dart';
|
||||||
import '../utils/app_time.dart';
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
|
/// Admin user query parameters for server-side pagination.
|
||||||
|
class AdminUserQuery {
|
||||||
|
/// Creates admin user query parameters.
|
||||||
|
const AdminUserQuery({
|
||||||
|
this.offset = 0,
|
||||||
|
this.limit = 50,
|
||||||
|
this.searchQuery = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Offset for pagination.
|
||||||
|
final int offset;
|
||||||
|
|
||||||
|
/// Number of items per page (default: 50).
|
||||||
|
final int limit;
|
||||||
|
|
||||||
|
/// Full text search query.
|
||||||
|
final String searchQuery;
|
||||||
|
|
||||||
|
AdminUserQuery copyWith({
|
||||||
|
int? offset,
|
||||||
|
int? limit,
|
||||||
|
String? searchQuery,
|
||||||
|
}) {
|
||||||
|
return AdminUserQuery(
|
||||||
|
offset: offset ?? this.offset,
|
||||||
|
limit: limit ?? this.limit,
|
||||||
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final adminUserQueryProvider = StateProvider<AdminUserQuery>((ref) => const AdminUserQuery());
|
||||||
|
|
||||||
final adminUserControllerProvider = Provider<AdminUserController>((ref) {
|
final adminUserControllerProvider = Provider<AdminUserController>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
return AdminUserController(client);
|
return AdminUserController(client);
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,75 @@ import 'dart:async';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import '../models/task.dart';
|
import '../models/task.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import '../models/task_assignment.dart';
|
import '../models/task_assignment.dart';
|
||||||
import 'profile_provider.dart';
|
import 'profile_provider.dart';
|
||||||
import 'supabase_provider.dart';
|
import 'supabase_provider.dart';
|
||||||
import 'tickets_provider.dart';
|
import 'tickets_provider.dart';
|
||||||
import 'user_offices_provider.dart';
|
import 'user_offices_provider.dart';
|
||||||
|
|
||||||
|
/// Task query parameters for server-side pagination and filtering.
|
||||||
|
class TaskQuery {
|
||||||
|
/// Creates task query parameters.
|
||||||
|
const TaskQuery({
|
||||||
|
this.offset = 0,
|
||||||
|
this.limit = 50,
|
||||||
|
this.searchQuery = '',
|
||||||
|
this.officeId,
|
||||||
|
this.status,
|
||||||
|
this.assigneeId,
|
||||||
|
this.dateRange,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Offset for pagination.
|
||||||
|
final int offset;
|
||||||
|
|
||||||
|
/// Number of items per page (default: 50).
|
||||||
|
final int limit;
|
||||||
|
|
||||||
|
/// Full text search query.
|
||||||
|
final String searchQuery;
|
||||||
|
|
||||||
|
/// Filter by office ID.
|
||||||
|
final String? officeId;
|
||||||
|
|
||||||
|
/// Filter by status.
|
||||||
|
final String? status;
|
||||||
|
|
||||||
|
/// Filter by assignee ID.
|
||||||
|
final String? assigneeId;
|
||||||
|
|
||||||
|
/// Filter by date range.
|
||||||
|
/// Filter by date range.
|
||||||
|
final DateTimeRange? dateRange;
|
||||||
|
|
||||||
|
TaskQuery copyWith({
|
||||||
|
int? offset,
|
||||||
|
int? limit,
|
||||||
|
String? searchQuery,
|
||||||
|
String? officeId,
|
||||||
|
String? status,
|
||||||
|
String? assigneeId,
|
||||||
|
DateTimeRange? dateRange,
|
||||||
|
}) {
|
||||||
|
return TaskQuery(
|
||||||
|
offset: offset ?? this.offset,
|
||||||
|
limit: limit ?? this.limit,
|
||||||
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
|
officeId: officeId ?? this.officeId,
|
||||||
|
status: status ?? this.status,
|
||||||
|
assigneeId: assigneeId ?? this.assigneeId,
|
||||||
|
dateRange: dateRange ?? this.dateRange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final tasksProvider = StreamProvider<List<Task>>((ref) {
|
final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
final profileAsync = ref.watch(currentProfileProvider);
|
final profileAsync = ref.watch(currentProfileProvider);
|
||||||
final ticketsAsync = ref.watch(ticketsProvider);
|
final ticketsAsync = ref.watch(ticketsProvider);
|
||||||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||||||
|
final query = ref.watch(tasksQueryProvider);
|
||||||
|
|
||||||
final profile = profileAsync.valueOrNull;
|
final profile = profileAsync.valueOrNull;
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
|
|
@ -25,15 +83,34 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||||
profile.role == 'dispatcher' ||
|
profile.role == 'dispatcher' ||
|
||||||
profile.role == 'it_staff';
|
profile.role == 'it_staff';
|
||||||
|
|
||||||
if (isGlobal) {
|
// For RBAC early-exit: if the user has no accessible tickets/offices,
|
||||||
return client
|
// avoid subscribing to the full tasks stream.
|
||||||
.from('tasks')
|
List<String> earlyAllowedTicketIds =
|
||||||
.stream(primaryKey: ['id'])
|
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
|
||||||
.order('queue_order', ascending: true)
|
<String>[];
|
||||||
.order('created_at')
|
List<String> earlyOfficeIds =
|
||||||
.map((rows) => rows.map(Task.fromMap).toList());
|
assignmentsAsync.valueOrNull
|
||||||
|
?.where((assignment) => assignment.userId == profile.id)
|
||||||
|
.map((assignment) => assignment.officeId)
|
||||||
|
.toSet()
|
||||||
|
.toList() ??
|
||||||
|
<String>[];
|
||||||
|
if (!isGlobal && earlyAllowedTicketIds.isEmpty && earlyOfficeIds.isEmpty) {
|
||||||
|
return Stream.value(const <Task>[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: Supabase stream builder does not support `.range(...)` —
|
||||||
|
// apply pagination and remaining filters client-side after mapping.
|
||||||
|
final baseStream = client
|
||||||
|
.from('tasks')
|
||||||
|
.stream(primaryKey: ['id'])
|
||||||
|
.map((rows) => rows.map(Task.fromMap).toList());
|
||||||
|
|
||||||
|
return baseStream.map((allTasks) {
|
||||||
|
// RBAC (server-side filtering isn't possible via `.range` on stream builder,
|
||||||
|
// so enforce allowed IDs here).
|
||||||
|
var list = allTasks;
|
||||||
|
if (!isGlobal) {
|
||||||
final allowedTicketIds =
|
final allowedTicketIds =
|
||||||
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
|
ticketsAsync.valueOrNull?.map((ticket) => ticket.id).toList() ??
|
||||||
<String>[];
|
<String>[];
|
||||||
|
|
@ -44,27 +121,56 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||||
.toSet()
|
.toSet()
|
||||||
.toList() ??
|
.toList() ??
|
||||||
<String>[];
|
<String>[];
|
||||||
|
if (allowedTicketIds.isEmpty && officeIds.isEmpty) return <Task>[];
|
||||||
if (allowedTicketIds.isEmpty && officeIds.isEmpty) {
|
final allowedTickets = allowedTicketIds.toSet();
|
||||||
return Stream.value(const <Task>[]);
|
final allowedOffices = officeIds.toSet();
|
||||||
|
list = list
|
||||||
|
.where(
|
||||||
|
(t) =>
|
||||||
|
(t.ticketId != null && allowedTickets.contains(t.ticketId)) ||
|
||||||
|
(t.officeId != null && allowedOffices.contains(t.officeId)),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return client
|
// Query filters (apply client-side)
|
||||||
.from('tasks')
|
if (query.officeId != null) {
|
||||||
.stream(primaryKey: ['id'])
|
list = list.where((t) => t.officeId == query.officeId).toList();
|
||||||
.order('queue_order', ascending: true)
|
}
|
||||||
.order('created_at')
|
if (query.status != null) {
|
||||||
.map(
|
list = list.where((t) => t.status == query.status).toList();
|
||||||
(rows) => rows.map(Task.fromMap).where((task) {
|
}
|
||||||
final matchesTicket =
|
if (query.searchQuery.isNotEmpty) {
|
||||||
task.ticketId != null && allowedTicketIds.contains(task.ticketId);
|
final q = query.searchQuery.toLowerCase();
|
||||||
final matchesOffice =
|
list = list
|
||||||
task.officeId != null && officeIds.contains(task.officeId);
|
.where(
|
||||||
return matchesTicket || matchesOffice;
|
(t) =>
|
||||||
}).toList(),
|
t.title.toLowerCase().contains(q) ||
|
||||||
);
|
t.description.toLowerCase().contains(q),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: queue_order ASC, then created_at ASC
|
||||||
|
list.sort((a, b) {
|
||||||
|
final aOrder = a.queueOrder ?? 0x7fffffff;
|
||||||
|
final bOrder = b.queueOrder ?? 0x7fffffff;
|
||||||
|
final cmp = aOrder.compareTo(bOrder);
|
||||||
|
if (cmp != 0) return cmp;
|
||||||
|
return a.createdAt.compareTo(b.createdAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pagination (server-side semantics emulated client-side)
|
||||||
|
final start = query.offset;
|
||||||
|
final end = (start + query.limit).clamp(0, list.length);
|
||||||
|
if (start >= list.length) return <Task>[];
|
||||||
|
return list.sublist(start, end);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider for task query parameters.
|
||||||
|
final tasksQueryProvider = StateProvider<TaskQuery>((ref) => const TaskQuery());
|
||||||
|
|
||||||
final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
|
final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
return client
|
return client
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/office.dart';
|
import '../models/office.dart';
|
||||||
import '../models/ticket.dart';
|
import '../models/ticket.dart';
|
||||||
|
|
@ -27,15 +28,93 @@ final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
|
||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Office query parameters for server-side pagination.
|
||||||
|
class OfficeQuery {
|
||||||
|
/// Creates office query parameters.
|
||||||
|
const OfficeQuery({this.offset = 0, this.limit = 50, this.searchQuery = ''});
|
||||||
|
|
||||||
|
/// Offset for pagination.
|
||||||
|
final int offset;
|
||||||
|
|
||||||
|
/// Number of items per page (default: 50).
|
||||||
|
final int limit;
|
||||||
|
|
||||||
|
/// Full text search query.
|
||||||
|
final String searchQuery;
|
||||||
|
|
||||||
|
OfficeQuery copyWith({int? offset, int? limit, String? searchQuery}) {
|
||||||
|
return OfficeQuery(
|
||||||
|
offset: offset ?? this.offset,
|
||||||
|
limit: limit ?? this.limit,
|
||||||
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final officesQueryProvider = StateProvider<OfficeQuery>(
|
||||||
|
(ref) => const OfficeQuery(),
|
||||||
|
);
|
||||||
|
|
||||||
final officesControllerProvider = Provider<OfficesController>((ref) {
|
final officesControllerProvider = Provider<OfficesController>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
return OfficesController(client);
|
return OfficesController(client);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Ticket query parameters for server-side pagination and filtering.
|
||||||
|
class TicketQuery {
|
||||||
|
/// Creates ticket query parameters.
|
||||||
|
const TicketQuery({
|
||||||
|
this.offset = 0,
|
||||||
|
this.limit = 50,
|
||||||
|
this.searchQuery = '',
|
||||||
|
this.officeId,
|
||||||
|
this.status,
|
||||||
|
this.dateRange,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Offset for pagination.
|
||||||
|
final int offset;
|
||||||
|
|
||||||
|
/// Number of items per page (default: 50).
|
||||||
|
final int limit;
|
||||||
|
|
||||||
|
/// Full text search query.
|
||||||
|
final String searchQuery;
|
||||||
|
|
||||||
|
/// Filter by office ID.
|
||||||
|
final String? officeId;
|
||||||
|
|
||||||
|
/// Filter by status.
|
||||||
|
final String? status;
|
||||||
|
|
||||||
|
/// Filter by date range.
|
||||||
|
/// Filter by date range.
|
||||||
|
final DateTimeRange? dateRange;
|
||||||
|
|
||||||
|
TicketQuery copyWith({
|
||||||
|
int? offset,
|
||||||
|
int? limit,
|
||||||
|
String? searchQuery,
|
||||||
|
String? officeId,
|
||||||
|
String? status,
|
||||||
|
DateTimeRange? dateRange,
|
||||||
|
}) {
|
||||||
|
return TicketQuery(
|
||||||
|
offset: offset ?? this.offset,
|
||||||
|
limit: limit ?? this.limit,
|
||||||
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
|
officeId: officeId ?? this.officeId,
|
||||||
|
status: status ?? this.status,
|
||||||
|
dateRange: dateRange ?? this.dateRange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
final profileAsync = ref.watch(currentProfileProvider);
|
final profileAsync = ref.watch(currentProfileProvider);
|
||||||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||||||
|
final query = ref.watch(ticketsQueryProvider);
|
||||||
|
|
||||||
final profile = profileAsync.valueOrNull;
|
final profile = profileAsync.valueOrNull;
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
|
|
@ -47,14 +126,17 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||||
profile.role == 'dispatcher' ||
|
profile.role == 'dispatcher' ||
|
||||||
profile.role == 'it_staff';
|
profile.role == 'it_staff';
|
||||||
|
|
||||||
if (isGlobal) {
|
// Use stream for realtime updates, then apply pagination & search filters
|
||||||
return client
|
// client-side because `.range(...)` is not supported on the stream builder.
|
||||||
|
final baseStream = client
|
||||||
.from('tickets')
|
.from('tickets')
|
||||||
.stream(primaryKey: ['id'])
|
.stream(primaryKey: ['id'])
|
||||||
.order('created_at', ascending: false)
|
|
||||||
.map((rows) => rows.map(Ticket.fromMap).toList());
|
.map((rows) => rows.map(Ticket.fromMap).toList());
|
||||||
}
|
|
||||||
|
|
||||||
|
return baseStream.map((allTickets) {
|
||||||
|
var list = allTickets;
|
||||||
|
|
||||||
|
if (!isGlobal) {
|
||||||
final officeIds =
|
final officeIds =
|
||||||
assignmentsAsync.valueOrNull
|
assignmentsAsync.valueOrNull
|
||||||
?.where((assignment) => assignment.userId == profile.id)
|
?.where((assignment) => assignment.userId == profile.id)
|
||||||
|
|
@ -62,17 +144,43 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
|
||||||
.toSet()
|
.toSet()
|
||||||
.toList() ??
|
.toList() ??
|
||||||
<String>[];
|
<String>[];
|
||||||
if (officeIds.isEmpty) {
|
if (officeIds.isEmpty) return <Ticket>[];
|
||||||
return Stream.value(const <Ticket>[]);
|
final allowedOffices = officeIds.toSet();
|
||||||
|
list = list.where((t) => allowedOffices.contains(t.officeId)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return client
|
if (query.officeId != null) {
|
||||||
.from('tickets')
|
list = list.where((t) => t.officeId == query.officeId).toList();
|
||||||
.stream(primaryKey: ['id'])
|
}
|
||||||
.inFilter('office_id', officeIds)
|
if (query.status != null) {
|
||||||
.order('created_at', ascending: false)
|
list = list.where((t) => t.status == query.status).toList();
|
||||||
.map((rows) => rows.map(Ticket.fromMap).toList());
|
}
|
||||||
|
if (query.searchQuery.isNotEmpty) {
|
||||||
|
final q = query.searchQuery.toLowerCase();
|
||||||
|
list = list
|
||||||
|
.where(
|
||||||
|
(t) =>
|
||||||
|
t.subject.toLowerCase().contains(q) ||
|
||||||
|
t.description.toLowerCase().contains(q),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: newest first
|
||||||
|
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
final start = query.offset;
|
||||||
|
final end = (start + query.limit).clamp(0, list.length);
|
||||||
|
if (start >= list.length) return <Ticket>[];
|
||||||
|
return list.sublist(start, end);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Provider for ticket query parameters.
|
||||||
|
final ticketsQueryProvider = StateProvider<TicketQuery>(
|
||||||
|
(ref) => const TicketQuery(),
|
||||||
|
);
|
||||||
|
|
||||||
final ticketMessagesProvider =
|
final ticketMessagesProvider =
|
||||||
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
|
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
|
@ -57,16 +58,38 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||||
RealtimeChannel? _channel;
|
RealtimeChannel? _channel;
|
||||||
Timer? _typingTimer;
|
Timer? _typingTimer;
|
||||||
final Map<String, Timer> _remoteTimeouts = {};
|
final Map<String, Timer> _remoteTimeouts = {};
|
||||||
|
// Marked when dispose() starts to prevent late async callbacks mutating state.
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
void _initChannel() {
|
void _initChannel() {
|
||||||
final channel = _client.channel('typing:$_ticketId');
|
final channel = _client.channel('typing:$_ticketId');
|
||||||
channel.onBroadcast(
|
channel.onBroadcast(
|
||||||
event: 'typing',
|
event: 'typing',
|
||||||
callback: (payload) {
|
callback: (payload) {
|
||||||
|
// Prevent any work if we're already disposing. Log stack for diagnostics.
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController: onBroadcast skipped (disposed|unmounted)',
|
||||||
|
);
|
||||||
|
debugPrint(StackTrace.current.toString());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, dynamic> data = _extractPayload(payload);
|
final Map<String, dynamic> data = _extractPayload(payload);
|
||||||
final userId = data['user_id'] as String?;
|
final userId = data['user_id'] as String?;
|
||||||
final rawType = data['type']?.toString();
|
final rawType = data['type']?.toString();
|
||||||
final currentUserId = _client.auth.currentUser?.id;
|
final currentUserId = _client.auth.currentUser?.id;
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController: payload received but controller disposed/unmounted',
|
||||||
|
);
|
||||||
|
debugPrint(StackTrace.current.toString());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
state = state.copyWith(lastPayload: data);
|
state = state.copyWith(lastPayload: data);
|
||||||
if (userId == null || userId == currentUserId) {
|
if (userId == null || userId == currentUserId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -79,6 +102,15 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
channel.subscribe((status, error) {
|
channel.subscribe((status, error) {
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController: subscribe callback skipped (disposed|unmounted)',
|
||||||
|
);
|
||||||
|
debugPrint(StackTrace.current.toString());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
state = state.copyWith(channelStatus: status.name);
|
state = state.copyWith(channelStatus: status.name);
|
||||||
});
|
});
|
||||||
_channel = channel;
|
_channel = channel;
|
||||||
|
|
@ -100,36 +132,83 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void userTyping() {
|
void userTyping() {
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode)
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController.userTyping() ignored after dispose',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_client.auth.currentUser?.id == null) return;
|
if (_client.auth.currentUser?.id == null) return;
|
||||||
_sendTypingEvent('start');
|
_sendTypingEvent('start');
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
_typingTimer = Timer(const Duration(milliseconds: 150), () {
|
_typingTimer = Timer(const Duration(milliseconds: 150), () {
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode)
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController._typingTimer callback ignored after dispose',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
_sendTypingEvent('stop');
|
_sendTypingEvent('stop');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopTyping() {
|
void stopTyping() {
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode)
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController.stopTyping() ignored after dispose',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
_sendTypingEvent('stop');
|
_sendTypingEvent('stop');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _markRemoteTyping(String userId) {
|
void _markRemoteTyping(String userId) {
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController._markRemoteTyping ignored after dispose for user: $userId',
|
||||||
|
);
|
||||||
|
debugPrint(StackTrace.current.toString());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
final updated = {...state.userIds, userId};
|
final updated = {...state.userIds, userId};
|
||||||
|
if (_disposed || !mounted) return;
|
||||||
state = state.copyWith(userIds: updated);
|
state = state.copyWith(userIds: updated);
|
||||||
_remoteTimeouts[userId]?.cancel();
|
_remoteTimeouts[userId]?.cancel();
|
||||||
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
|
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode)
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController.remote timeout callback ignored after dispose for user: $userId',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
_clearRemoteTyping(userId);
|
_clearRemoteTyping(userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearRemoteTyping(String userId) {
|
void _clearRemoteTyping(String userId) {
|
||||||
|
if (_disposed || !mounted) {
|
||||||
|
if (kDebugMode)
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController._clearRemoteTyping ignored after dispose for user: $userId',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final updated = {...state.userIds}..remove(userId);
|
final updated = {...state.userIds}..remove(userId);
|
||||||
|
if (_disposed || !mounted) return;
|
||||||
state = state.copyWith(userIds: updated);
|
state = state.copyWith(userIds: updated);
|
||||||
_remoteTimeouts[userId]?.cancel();
|
_remoteTimeouts[userId]?.cancel();
|
||||||
_remoteTimeouts.remove(userId);
|
_remoteTimeouts.remove(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendTypingEvent(String type) {
|
void _sendTypingEvent(String type) {
|
||||||
|
if (_disposed || !mounted) return;
|
||||||
final userId = _client.auth.currentUser?.id;
|
final userId = _client.auth.currentUser?.id;
|
||||||
if (userId == null || _channel == null) return;
|
if (userId == null || _channel == null) return;
|
||||||
_channel!.sendBroadcastMessage(
|
_channel!.sendBroadcastMessage(
|
||||||
|
|
@ -138,15 +217,36 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exposed for tests only: simulate a remote typing broadcast.
|
||||||
|
@visibleForTesting
|
||||||
|
void debugSimulateRemoteTyping(String userId, {bool stop = false}) {
|
||||||
|
if (_disposed || !mounted) return;
|
||||||
|
final data = {'user_id': userId, 'type': stop ? 'stop' : 'start'};
|
||||||
|
state = state.copyWith(lastPayload: data);
|
||||||
|
if (stop) {
|
||||||
|
_clearRemoteTyping(userId);
|
||||||
|
} else {
|
||||||
|
_markRemoteTyping(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
stopTyping();
|
// Mark disposed first so any late async callbacks will no-op.
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
|
// Cancel local timers and remote timeouts; do NOT send network events during
|
||||||
|
// dispose (prevents broadcasts from re-entering callbacks after disposal).
|
||||||
_typingTimer?.cancel();
|
_typingTimer?.cancel();
|
||||||
for (final timer in _remoteTimeouts.values) {
|
for (final timer in _remoteTimeouts.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
_remoteTimeouts.clear();
|
_remoteTimeouts.clear();
|
||||||
|
|
||||||
|
// Unsubscribe from realtime channel.
|
||||||
_channel?.unsubscribe();
|
_channel?.unsubscribe();
|
||||||
|
_channel = null;
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,12 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onRequestRefresh: () {
|
||||||
|
// For server-side pagination, update the query provider
|
||||||
|
ref.read(officesQueryProvider.notifier).state =
|
||||||
|
const OfficeQuery(offset: 0, limit: 50);
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onRequestRefresh: () {
|
||||||
|
// For server-side pagination, update the query provider
|
||||||
|
ref.read(adminUserQueryProvider.notifier).state =
|
||||||
|
const AdminUserQuery(offset: 0, limit: 50);
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@ import 'package:flutter/material.dart';
|
||||||
/// Searchable multi-select dropdown with chips and 'Select All' option
|
/// Searchable multi-select dropdown with chips and 'Select All' option
|
||||||
class SearchableMultiSelectDropdown<T> extends StatefulWidget {
|
class SearchableMultiSelectDropdown<T> extends StatefulWidget {
|
||||||
const SearchableMultiSelectDropdown({
|
const SearchableMultiSelectDropdown({
|
||||||
Key? key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.items,
|
required this.items,
|
||||||
required this.selectedIds,
|
required this.selectedIds,
|
||||||
required this.getId,
|
required this.getId,
|
||||||
required this.getLabel,
|
required this.getLabel,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
}) : super(key: key);
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final List<T> items;
|
final List<T> items;
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
onRowTap: (task) => context.go('/tasks/${task.id}'),
|
onRowTap: (task) => context.go('/tasks/${task.id}'),
|
||||||
summaryDashboard: summaryDashboard,
|
summaryDashboard: summaryDashboard,
|
||||||
filterHeader: filterHeader,
|
filterHeader: filterHeader,
|
||||||
|
onRequestRefresh: () {
|
||||||
|
// For server-side pagination, update the query provider
|
||||||
|
ref.read(tasksQueryProvider.notifier).state =
|
||||||
|
const TaskQuery(offset: 0, limit: 50);
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
columns: [
|
columns: [
|
||||||
TasQColumn<Task>(
|
TasQColumn<Task>(
|
||||||
header: 'Task ID',
|
header: 'Task ID',
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ final officesProvider = FutureProvider<List<Office>>((ref) async {
|
||||||
});
|
});
|
||||||
|
|
||||||
class TeamsScreen extends ConsumerWidget {
|
class TeamsScreen extends ConsumerWidget {
|
||||||
const TeamsScreen({Key? key}) : super(key: key);
|
const TeamsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
|
@ -127,6 +127,7 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
}) async {
|
}) async {
|
||||||
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
||||||
final offices = await ref.read(officesProvider.future);
|
final offices = await ref.read(officesProvider.future);
|
||||||
|
if (!context.mounted) return;
|
||||||
final itStaff = profiles.where((p) => p.role == 'it_staff').toList();
|
final itStaff = profiles.where((p) => p.role == 'it_staff').toList();
|
||||||
final nameController = TextEditingController(text: team?.name ?? '');
|
final nameController = TextEditingController(text: team?.name ?? '');
|
||||||
String? leaderId = team?.leaderId;
|
String? leaderId = team?.leaderId;
|
||||||
|
|
@ -204,6 +205,7 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
final name = nameController.text.trim();
|
final name = nameController.text.trim();
|
||||||
if (name.isEmpty || leaderId == null) return;
|
if (name.isEmpty || leaderId == null) return;
|
||||||
final client = Supabase.instance.client;
|
final client = Supabase.instance.client;
|
||||||
|
|
@ -247,7 +249,7 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
ref.invalidate(teamsProvider);
|
ref.invalidate(teamsProvider);
|
||||||
ref.invalidate(teamMembersProvider);
|
ref.invalidate(teamMembersProvider);
|
||||||
Navigator.of(context).pop();
|
navigator.pop();
|
||||||
},
|
},
|
||||||
child: Text(isEdit ? 'Save' : 'Add'),
|
child: Text(isEdit ? 'Save' : 'Add'),
|
||||||
),
|
),
|
||||||
|
|
@ -260,6 +262,7 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _deleteTeam(BuildContext context, WidgetRef ref, String teamId) async {
|
void _deleteTeam(BuildContext context, WidgetRef ref, String teamId) async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
|
|
@ -267,11 +270,11 @@ class TeamsScreen extends ConsumerWidget {
|
||||||
content: const Text('Are you sure you want to delete this team?'),
|
content: const Text('Are you sure you want to delete this team?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
onPressed: () => navigator.pop(false),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => navigator.pop(true),
|
||||||
child: const Text('Delete'),
|
child: const Text('Delete'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,13 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
|
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
|
||||||
summaryDashboard: summaryDashboard,
|
summaryDashboard: summaryDashboard,
|
||||||
filterHeader: filterHeader,
|
filterHeader: filterHeader,
|
||||||
|
onRequestRefresh: () {
|
||||||
|
// For server-side pagination, update the query provider
|
||||||
|
// This will trigger a reload with new pagination parameters
|
||||||
|
ref.read(ticketsQueryProvider.notifier).state =
|
||||||
|
const TicketQuery(offset: 0, limit: 50);
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
columns: [
|
columns: [
|
||||||
TasQColumn<Ticket>(
|
TasQColumn<Ticket>(
|
||||||
header: 'Ticket ID',
|
header: 'Ticket ID',
|
||||||
|
|
|
||||||
|
|
@ -5,26 +5,43 @@ import 'package:flutter/material.dart';
|
||||||
import '../theme/app_typography.dart';
|
import '../theme/app_typography.dart';
|
||||||
import 'mono_text.dart';
|
import 'mono_text.dart';
|
||||||
|
|
||||||
|
/// A column configuration for the [TasQAdaptiveList] desktop table view.
|
||||||
class TasQColumn<T> {
|
class TasQColumn<T> {
|
||||||
|
/// Creates a column configuration.
|
||||||
const TasQColumn({
|
const TasQColumn({
|
||||||
required this.header,
|
required this.header,
|
||||||
required this.cellBuilder,
|
required this.cellBuilder,
|
||||||
this.technical = false,
|
this.technical = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The column header text.
|
||||||
final String header;
|
final String header;
|
||||||
|
|
||||||
|
/// Builds the cell content for each row.
|
||||||
final Widget Function(BuildContext context, T item) cellBuilder;
|
final Widget Function(BuildContext context, T item) cellBuilder;
|
||||||
|
|
||||||
|
/// If true, applies monospace text style to the cell content.
|
||||||
final bool technical;
|
final bool technical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a mobile tile for [TasQAdaptiveList].
|
||||||
typedef TasQMobileTileBuilder<T> =
|
typedef TasQMobileTileBuilder<T> =
|
||||||
Widget Function(BuildContext context, T item, List<Widget> actions);
|
Widget Function(BuildContext context, T item, List<Widget> actions);
|
||||||
|
|
||||||
|
/// Returns a list of action widgets for a given item.
|
||||||
typedef TasQRowActions<T> = List<Widget> Function(T item);
|
typedef TasQRowActions<T> = List<Widget> Function(T item);
|
||||||
|
|
||||||
|
/// Callback when a row is tapped.
|
||||||
typedef TasQRowTap<T> = void Function(T item);
|
typedef TasQRowTap<T> = void Function(T item);
|
||||||
|
|
||||||
|
/// A adaptive list widget that renders as:
|
||||||
|
/// - **Mobile**: Tile-based list with infinite scroll listeners.
|
||||||
|
/// - **Desktop**: Data Table with paginated footer.
|
||||||
|
///
|
||||||
|
/// The widget requires a reactive data source ([items]) that responds to
|
||||||
|
/// pagination/search providers for server-side data fetching.
|
||||||
class TasQAdaptiveList<T> extends StatelessWidget {
|
class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
|
/// Creates an adaptive list.
|
||||||
const TasQAdaptiveList({
|
const TasQAdaptiveList({
|
||||||
super.key,
|
super.key,
|
||||||
required this.items,
|
required this.items,
|
||||||
|
|
@ -32,52 +49,122 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
required this.mobileTileBuilder,
|
required this.mobileTileBuilder,
|
||||||
this.rowActions,
|
this.rowActions,
|
||||||
this.onRowTap,
|
this.onRowTap,
|
||||||
this.rowsPerPage = 25,
|
this.rowsPerPage = 50,
|
||||||
this.tableHeader,
|
this.tableHeader,
|
||||||
this.filterHeader,
|
this.filterHeader,
|
||||||
this.summaryDashboard,
|
this.summaryDashboard,
|
||||||
|
this.onRequestRefresh,
|
||||||
|
this.isLoading = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The list of items to display.
|
||||||
final List<T> items;
|
final List<T> items;
|
||||||
|
|
||||||
|
/// The column configurations for the desktop table view.
|
||||||
final List<TasQColumn<T>> columns;
|
final List<TasQColumn<T>> columns;
|
||||||
|
|
||||||
|
/// Builds the mobile tile for each item.
|
||||||
final TasQMobileTileBuilder<T> mobileTileBuilder;
|
final TasQMobileTileBuilder<T> mobileTileBuilder;
|
||||||
|
|
||||||
|
/// Returns action widgets for each row (e.g., edit/delete buttons).
|
||||||
final TasQRowActions<T>? rowActions;
|
final TasQRowActions<T>? rowActions;
|
||||||
|
|
||||||
|
/// Callback when a row is tapped.
|
||||||
final TasQRowTap<T>? onRowTap;
|
final TasQRowTap<T>? onRowTap;
|
||||||
|
|
||||||
|
/// Number of rows per page for desktop view.
|
||||||
|
///
|
||||||
|
/// Per CLAUDE.md: Standard page size is 50 items for Desktop.
|
||||||
final int rowsPerPage;
|
final int rowsPerPage;
|
||||||
|
|
||||||
|
/// Optional header widget for the desktop table.
|
||||||
final Widget? tableHeader;
|
final Widget? tableHeader;
|
||||||
|
|
||||||
|
/// Optional filter header widget that appears above the list/table.
|
||||||
final Widget? filterHeader;
|
final Widget? filterHeader;
|
||||||
|
|
||||||
|
/// Optional summary dashboard widget (e.g., status counts).
|
||||||
final Widget? summaryDashboard;
|
final Widget? summaryDashboard;
|
||||||
|
|
||||||
|
/// Callback when the user requests refresh (infinite scroll or pagination).
|
||||||
|
final void Function()? onRequestRefresh;
|
||||||
|
|
||||||
|
/// If true, shows a loading indicator for server-side pagination.
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isMobile = constraints.maxWidth < 600;
|
final isMobile = constraints.maxWidth < 600;
|
||||||
final hasBoundedHeight = constraints.hasBoundedHeight;
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
final listView = ListView.separated(
|
return _buildMobile(context, constraints);
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
}
|
||||||
itemCount: items.length,
|
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
return _buildDesktop(context, constraints);
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
|
||||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
|
||||||
return mobileTileBuilder(context, item, actions);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final shrinkWrappedList = ListView.separated(
|
}
|
||||||
|
|
||||||
|
Widget _buildMobile(BuildContext context, BoxConstraints constraints) {
|
||||||
|
final hasBoundedHeight = constraints.hasBoundedHeight;
|
||||||
|
|
||||||
|
// Mobile: Single-column with infinite scroll listeners
|
||||||
|
final listView = ListView.separated(
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
itemCount: items.length,
|
itemCount: items.length + (isLoading ? 1 : 0),
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= items.length) {
|
||||||
|
// Loading indicator for infinite scroll
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||||
return mobileTileBuilder(context, item, actions);
|
return _MobileTile(
|
||||||
|
item: item,
|
||||||
|
actions: actions,
|
||||||
|
mobileTileBuilder: mobileTileBuilder,
|
||||||
|
onRowTap: onRowTap,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shrink-wrapped list for unbounded height contexts
|
||||||
|
final shrinkWrappedList = ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
itemCount: items.length + (isLoading ? 1 : 0),
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index >= items.length) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 24,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final item = items[index];
|
||||||
|
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||||
|
return _MobileTile(
|
||||||
|
item: item,
|
||||||
|
actions: actions,
|
||||||
|
mobileTileBuilder: mobileTileBuilder,
|
||||||
|
onRowTap: onRowTap,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
);
|
);
|
||||||
|
|
||||||
final summarySection = summaryDashboard == null
|
final summarySection = summaryDashboard == null
|
||||||
? null
|
? null
|
||||||
: <Widget>[
|
: <Widget>[
|
||||||
|
|
@ -98,6 +185,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -106,13 +194,33 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
...?summarySection,
|
...?summarySection,
|
||||||
...?filterSection,
|
...?filterSection,
|
||||||
if (hasBoundedHeight) Expanded(child: listView),
|
if (hasBoundedHeight)
|
||||||
if (!hasBoundedHeight) shrinkWrappedList,
|
Expanded(child: _buildInfiniteScrollListener(listView))
|
||||||
|
else
|
||||||
|
_buildInfiniteScrollListener(shrinkWrappedList),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildInfiniteScrollListener(Widget listView) {
|
||||||
|
if (onRequestRefresh == null) {
|
||||||
|
return listView;
|
||||||
|
}
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
if (notification is ScrollEndNotification &&
|
||||||
|
notification.metrics.extentAfter == 0) {
|
||||||
|
// User scrolled to bottom, trigger load more
|
||||||
|
onRequestRefresh!();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: listView,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktop(BuildContext context, BoxConstraints constraints) {
|
||||||
final dataSource = _TasQTableSource<T>(
|
final dataSource = _TasQTableSource<T>(
|
||||||
context: context,
|
context: context,
|
||||||
items: items,
|
items: items,
|
||||||
|
|
@ -120,6 +228,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
onRowTap: onRowTap,
|
onRowTap: onRowTap,
|
||||||
);
|
);
|
||||||
|
|
||||||
final contentWidth = constraints.maxWidth * 0.8;
|
final contentWidth = constraints.maxWidth * 0.8;
|
||||||
final tableWidth = math.max(
|
final tableWidth = math.max(
|
||||||
contentWidth,
|
contentWidth,
|
||||||
|
|
@ -129,6 +238,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
rowsPerPage,
|
rowsPerPage,
|
||||||
math.max(1, items.length),
|
math.max(1, items.length),
|
||||||
);
|
);
|
||||||
|
|
||||||
final tableWidget = SingleChildScrollView(
|
final tableWidget = SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
|
@ -145,8 +255,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
columns: [
|
columns: [
|
||||||
for (final column in columns)
|
for (final column in columns)
|
||||||
DataColumn(label: Text(column.header)),
|
DataColumn(label: Text(column.header)),
|
||||||
if (rowActions != null)
|
if (rowActions != null) const DataColumn(label: Text('Actions')),
|
||||||
const DataColumn(label: Text('Actions')),
|
|
||||||
],
|
],
|
||||||
source: dataSource,
|
source: dataSource,
|
||||||
),
|
),
|
||||||
|
|
@ -164,7 +273,7 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
: <Widget>[filterHeader!, const SizedBox(height: 12)];
|
: <Widget>[filterHeader!, const SizedBox(height: 12)];
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
primary: hasBoundedHeight,
|
primary: true,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: contentWidth,
|
width: contentWidth,
|
||||||
|
|
@ -176,12 +285,45 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mobile tile wrapper that applies Material 2 style elevation.
|
||||||
|
class _MobileTile<T> extends StatelessWidget {
|
||||||
|
const _MobileTile({
|
||||||
|
required this.item,
|
||||||
|
required this.actions,
|
||||||
|
required this.mobileTileBuilder,
|
||||||
|
required this.onRowTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final T item;
|
||||||
|
final List<Widget> actions;
|
||||||
|
final TasQMobileTileBuilder<T> mobileTileBuilder;
|
||||||
|
final TasQRowTap<T>? onRowTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tile = mobileTileBuilder(context, item, actions);
|
||||||
|
|
||||||
|
// Apply Material 2 style elevation for Cards (per Hybrid M3/M2 guidelines)
|
||||||
|
if (tile is Card) {
|
||||||
|
return Card(
|
||||||
|
color: tile.color,
|
||||||
|
elevation: 2,
|
||||||
|
margin: tile.margin,
|
||||||
|
shape: tile.shape,
|
||||||
|
clipBehavior: tile.clipBehavior,
|
||||||
|
child: tile.child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TasQTableSource<T> extends DataTableSource {
|
class _TasQTableSource<T> extends DataTableSource {
|
||||||
|
/// Creates a table source for [TasQAdaptiveList].
|
||||||
_TasQTableSource({
|
_TasQTableSource({
|
||||||
required this.context,
|
required this.context,
|
||||||
required this.items,
|
required this.items,
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,39 @@ import 'package:tasq/models/ticket.dart';
|
||||||
import 'package:tasq/models/user_office.dart';
|
import 'package:tasq/models/user_office.dart';
|
||||||
import 'package:tasq/providers/notifications_provider.dart';
|
import 'package:tasq/providers/notifications_provider.dart';
|
||||||
import 'package:tasq/providers/profile_provider.dart';
|
import 'package:tasq/providers/profile_provider.dart';
|
||||||
|
|
||||||
import 'package:tasq/providers/tasks_provider.dart';
|
import 'package:tasq/providers/tasks_provider.dart';
|
||||||
import 'package:tasq/providers/tickets_provider.dart';
|
import 'package:tasq/providers/tickets_provider.dart';
|
||||||
import 'package:tasq/providers/typing_provider.dart';
|
import 'package:tasq/providers/typing_provider.dart';
|
||||||
import 'package:tasq/providers/user_offices_provider.dart';
|
import 'package:tasq/providers/user_offices_provider.dart';
|
||||||
|
import 'package:tasq/providers/supabase_provider.dart';
|
||||||
import 'package:tasq/screens/admin/offices_screen.dart';
|
import 'package:tasq/screens/admin/offices_screen.dart';
|
||||||
import 'package:tasq/screens/admin/user_management_screen.dart';
|
import 'package:tasq/screens/admin/user_management_screen.dart';
|
||||||
import 'package:tasq/screens/tasks/tasks_list_screen.dart';
|
import 'package:tasq/screens/tasks/tasks_list_screen.dart';
|
||||||
import 'package:tasq/screens/tickets/tickets_list_screen.dart';
|
import 'package:tasq/screens/tickets/tickets_list_screen.dart';
|
||||||
|
import 'package:tasq/screens/tickets/ticket_detail_screen.dart';
|
||||||
|
|
||||||
|
// Test double for NotificationsController so widget tests don't initialize
|
||||||
|
// a real Supabase client.
|
||||||
|
class FakeNotificationsController implements NotificationsController {
|
||||||
|
@override
|
||||||
|
Future<void> createMentionNotifications({
|
||||||
|
required List<String> userIds,
|
||||||
|
required String actorId,
|
||||||
|
required int messageId,
|
||||||
|
String? ticketId,
|
||||||
|
String? taskId,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markRead(String id) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markReadForTicket(String ticketId) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> markReadForTask(String taskId) async {}
|
||||||
|
}
|
||||||
|
|
||||||
SupabaseClient _fakeSupabaseClient() {
|
SupabaseClient _fakeSupabaseClient() {
|
||||||
return SupabaseClient('http://localhost', 'test-key');
|
return SupabaseClient('http://localhost', 'test-key');
|
||||||
|
|
@ -69,8 +94,10 @@ void main() {
|
||||||
|
|
||||||
List<Override> baseOverrides() {
|
List<Override> baseOverrides() {
|
||||||
return [
|
return [
|
||||||
|
supabaseClientProvider.overrideWithValue(_fakeSupabaseClient()),
|
||||||
currentProfileProvider.overrideWith((ref) => Stream.value(admin)),
|
currentProfileProvider.overrideWith((ref) => Stream.value(admin)),
|
||||||
profilesProvider.overrideWith((ref) => Stream.value([admin, tech])),
|
profilesProvider.overrideWith((ref) => Stream.value([admin, tech])),
|
||||||
|
|
||||||
officesProvider.overrideWith((ref) => Stream.value([office])),
|
officesProvider.overrideWith((ref) => Stream.value([office])),
|
||||||
notificationsProvider.overrideWith((ref) => Stream.value([notification])),
|
notificationsProvider.overrideWith((ref) => Stream.value([notification])),
|
||||||
ticketsProvider.overrideWith((ref) => Stream.value([ticket])),
|
ticketsProvider.overrideWith((ref) => Stream.value([ticket])),
|
||||||
|
|
@ -81,6 +108,9 @@ void main() {
|
||||||
),
|
),
|
||||||
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
|
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
|
||||||
isAdminProvider.overrideWith((ref) => true),
|
isAdminProvider.overrideWith((ref) => true),
|
||||||
|
notificationsControllerProvider.overrideWithValue(
|
||||||
|
FakeNotificationsController(),
|
||||||
|
),
|
||||||
typingIndicatorProvider.overrideWithProvider(
|
typingIndicatorProvider.overrideWithProvider(
|
||||||
AutoDisposeStateNotifierProvider.family<
|
AutoDisposeStateNotifierProvider.family<
|
||||||
TypingIndicatorController,
|
TypingIndicatorController,
|
||||||
|
|
@ -158,6 +188,36 @@ void main() {
|
||||||
await tester.pump(const Duration(milliseconds: 16));
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Typing indicator: no post-dispose state mutation', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _setSurfaceSize(tester, const Size(600, 800));
|
||||||
|
|
||||||
|
// Show TicketDetailScreen with the base overrides (includes typing controller).
|
||||||
|
await _pumpScreen(
|
||||||
|
tester,
|
||||||
|
const TicketDetailScreen(ticketId: 'TCK-1'),
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Find message TextField and simulate typing.
|
||||||
|
final finder = find.byType(TextField);
|
||||||
|
expect(finder, findsWidgets);
|
||||||
|
await tester.enterText(finder.first, 'Hello');
|
||||||
|
|
||||||
|
// Immediately remove the screen (navigate away / dispose).
|
||||||
|
await tester.pumpWidget(Container());
|
||||||
|
|
||||||
|
// Let pending timers (typing stop, remote timeouts) run.
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// No unhandled exceptions should have been thrown.
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
36
test/typing_dispose_race_test.dart
Normal file
36
test/typing_dispose_race_test.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
import 'package:tasq/providers/typing_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'TypingIndicatorController ignores late remote events after dispose',
|
||||||
|
() async {
|
||||||
|
final client = SupabaseClient('http://localhost', 'test-key');
|
||||||
|
final controller = TypingIndicatorController(client, 'ticket-1');
|
||||||
|
|
||||||
|
// initial state should be empty
|
||||||
|
expect(controller.state.userIds, isEmpty);
|
||||||
|
|
||||||
|
// Simulate a remote "start typing" arriving while alive
|
||||||
|
controller.debugSimulateRemoteTyping('user-2');
|
||||||
|
expect(controller.state.userIds, contains('user-2'));
|
||||||
|
|
||||||
|
// Dispose the controller
|
||||||
|
controller.dispose();
|
||||||
|
|
||||||
|
// Calling the remote handlers after dispose must NOT throw and must be no-ops
|
||||||
|
expect(
|
||||||
|
() => controller.debugSimulateRemoteTyping('user-3'),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => controller.debugSimulateRemoteTyping('user-2', stop: true),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure state was not modified after dispose
|
||||||
|
expect(controller.mounted, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user