feat: 重构会话管理与提醒通知系统
This commit is contained in:
+35
-21
@@ -13,6 +13,7 @@ import '../features/auth/presentation/bloc/auth_state.dart';
|
||||
import '../features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import '../data/cache/cache_scope.dart';
|
||||
import 'services/app_prewarm_orchestrator.dart';
|
||||
import 'services/session_scope_manager.dart';
|
||||
import 'router/app_router.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
import '../core/inbox/inbox_sync_store.dart';
|
||||
@@ -33,21 +34,16 @@ class _LinksyAppState extends State<LinksyApp> {
|
||||
late final GoRouter _router;
|
||||
StreamSubscription<ReminderNotificationTap>? _reminderTapSubscription;
|
||||
String? _pendingReminderRoute;
|
||||
int _cacheScopeVersion = 0;
|
||||
Future<void> _authTransitionQueue = Future<void>.value();
|
||||
|
||||
Future<void> _onAuthenticated(String userId) async {
|
||||
_cacheScopeVersion += 1;
|
||||
final scopeKey = 'user:$userId:v$_cacheScopeVersion';
|
||||
CacheScope.configureProvider(() => scopeKey);
|
||||
await sl<SessionScopeManager>().activate(userId);
|
||||
await sl<InboxSyncStore>().resetForUser(userId);
|
||||
await sl<ChatBloc>().switchUser(userId);
|
||||
await sl<AppPrewarmOrchestrator>().ensureStartedFor(userId);
|
||||
}
|
||||
|
||||
Future<void> _onUnauthenticated() async {
|
||||
_cacheScopeVersion += 1;
|
||||
final scopeKey = 'anonymous:v$_cacheScopeVersion';
|
||||
CacheScope.configureProvider(() => scopeKey);
|
||||
await sl<SessionScopeManager>().clearActiveUserScope();
|
||||
await sl<InboxSyncStore>().resetForUser(null);
|
||||
await sl<ChatBloc>().switchUser(null);
|
||||
sl<AppPrewarmOrchestrator>().reset();
|
||||
@@ -57,8 +53,7 @@ class _LinksyAppState extends State<LinksyApp> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_authBloc = sl<AuthBloc>();
|
||||
const initialScopeKey = 'anonymous:v0';
|
||||
CacheScope.configureProvider(() => initialScopeKey);
|
||||
CacheScope.resetProvider();
|
||||
_authBloc.add(AuthStarted());
|
||||
_router = createAppRouter(_authBloc);
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -75,11 +70,18 @@ class _LinksyAppState extends State<LinksyApp> {
|
||||
|
||||
void _onReminderTap(ReminderNotificationTap tap) {
|
||||
final route = AppRoutes.calendarReminderAlarm(tap.eventId);
|
||||
if (_authBloc.state is AuthAuthenticated) {
|
||||
_router.go(route);
|
||||
return;
|
||||
}
|
||||
_pendingReminderRoute = route;
|
||||
_enqueueAuthTransition(() async {
|
||||
if (_authBloc.state is! AuthAuthenticated) {
|
||||
return;
|
||||
}
|
||||
final pendingRoute = _pendingReminderRoute;
|
||||
if (pendingRoute == null) {
|
||||
return;
|
||||
}
|
||||
_pendingReminderRoute = null;
|
||||
_router.go(pendingRoute);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,6 +91,23 @@ class _LinksyAppState extends State<LinksyApp> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _enqueueAuthTransition(Future<void> Function() transition) {
|
||||
_authTransitionQueue = _authTransitionQueue
|
||||
.catchError((Object error, StackTrace stackTrace) {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(exception: error, stack: stackTrace),
|
||||
);
|
||||
Zone.current.handleUncaughtError(error, stackTrace);
|
||||
})
|
||||
.then((_) => transition())
|
||||
.catchError((Object error, StackTrace stackTrace) {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(exception: error, stack: stackTrace),
|
||||
);
|
||||
Zone.current.handleUncaughtError(error, stackTrace);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<AuthBloc>.value(
|
||||
@@ -96,15 +115,10 @@ class _LinksyAppState extends State<LinksyApp> {
|
||||
child: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthAuthenticated) {
|
||||
unawaited(_onAuthenticated(state.user.id));
|
||||
final pendingRoute = _pendingReminderRoute;
|
||||
if (pendingRoute != null) {
|
||||
_pendingReminderRoute = null;
|
||||
_router.go(pendingRoute);
|
||||
}
|
||||
_enqueueAuthTransition(() => _onAuthenticated(state.user.id));
|
||||
}
|
||||
if (state is AuthUnauthenticated) {
|
||||
unawaited(_onUnauthenticated());
|
||||
_enqueueAuthTransition(_onUnauthenticated);
|
||||
}
|
||||
},
|
||||
child: MaterialApp.router(
|
||||
|
||||
@@ -36,6 +36,7 @@ import '../../features/contacts/data/apis/users_api.dart';
|
||||
import '../../features/todo/data/apis/todo_api.dart';
|
||||
import '../../features/todo/data/repositories/todo_repository.dart';
|
||||
import '../services/app_prewarm_orchestrator.dart';
|
||||
import '../services/session_scope_manager.dart';
|
||||
import '../services/auth_session_controller.dart';
|
||||
import '../../core/notification/services/reminder_scheduler_service.dart';
|
||||
import '../../core/notification/services/reminder_permission_service.dart';
|
||||
@@ -83,6 +84,9 @@ Future<void> configureDependencies() async {
|
||||
sl.registerSingleton<MemoryCacheStore>(memoryCacheStore);
|
||||
sl.registerSingleton<PersistentCacheStore>(persistentCacheStore);
|
||||
sl.registerSingleton<HybridCacheStore>(hybridCacheStore);
|
||||
sl.registerSingleton<SessionScopeManager>(
|
||||
SessionScopeManager(cacheStore: hybridCacheStore),
|
||||
);
|
||||
sl.registerSingleton<CacheInvalidator>(
|
||||
CacheInvalidator(store: hybridCacheStore),
|
||||
);
|
||||
|
||||
@@ -91,10 +91,11 @@ String? resolveAuthRedirect({
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget buildHomeRouteScreen() {
|
||||
Widget buildHomeRouteScreen(AuthState authState) {
|
||||
final userId = authState is AuthAuthenticated ? authState.user.id : null;
|
||||
return BlocProvider<ChatBloc>.value(
|
||||
value: sl<ChatBloc>(),
|
||||
child: const HomeScreen(),
|
||||
child: HomeScreen(initialUserId: userId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,7 +150,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.homeMain,
|
||||
builder: (context, state) => buildHomeRouteScreen(),
|
||||
builder: (context, state) => buildHomeRouteScreen(authBloc.state),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.messageInviteList,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import '../../data/cache/cache_scope.dart';
|
||||
import '../../data/cache/cache_store.dart';
|
||||
|
||||
class SessionScopeManager {
|
||||
SessionScopeManager({required HybridCacheStore cacheStore})
|
||||
: _cacheStore = cacheStore;
|
||||
|
||||
final HybridCacheStore _cacheStore;
|
||||
String? _activeUserId;
|
||||
|
||||
Future<void> activate(String userId) async {
|
||||
final normalizedUserId = userId.trim();
|
||||
if (normalizedUserId.isEmpty) {
|
||||
throw StateError('User id cannot be empty when activating cache scope');
|
||||
}
|
||||
|
||||
final previousUserId = _activeUserId;
|
||||
if (previousUserId == normalizedUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
_activeUserId = normalizedUserId;
|
||||
CacheScope.configureProvider(() => 'user:$normalizedUserId');
|
||||
|
||||
if (previousUserId != null && previousUserId != normalizedUserId) {
|
||||
await _clearUserCache(previousUserId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearActiveUserScope() async {
|
||||
final userId = _activeUserId;
|
||||
_activeUserId = null;
|
||||
CacheScope.resetProvider();
|
||||
if (userId != null) {
|
||||
await _clearUserCache(userId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearUserCache(String userId) {
|
||||
return _cacheStore.clearByPrefix('cache:user:$userId:');
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,12 @@ class ReminderSchedulerService {
|
||||
ReminderSchedulerService({FlutterLocalNotificationsPlugin? plugin})
|
||||
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
||||
|
||||
static const String _channelId = 'calendar_reminder_alarm_v2';
|
||||
static const String _channelId = 'calendar_reminder_alarm_v3';
|
||||
static const String _channelName = 'Schedule alarm';
|
||||
static const String _channelDescription =
|
||||
'Alarm-style notifications for scheduled events';
|
||||
static const String _androidSoundResource = 'reminder_1';
|
||||
static const String _iosSoundFile = 'reminder_1.wav';
|
||||
|
||||
final FlutterLocalNotificationsPlugin _plugin;
|
||||
final List<void Function(ReminderNotificationTap tap)> _tapCallbacks = [];
|
||||
@@ -45,6 +47,8 @@ class ReminderSchedulerService {
|
||||
_channelName,
|
||||
description: _channelDescription,
|
||||
importance: Importance.max,
|
||||
playSound: true,
|
||||
sound: RawResourceAndroidNotificationSound(_androidSoundResource),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -120,9 +124,18 @@ class ReminderSchedulerService {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
await _ensureInitialized();
|
||||
await cancelEventReminders(event.eventId);
|
||||
final alarms = buildAlarmsForEvent(event, now: now);
|
||||
for (final alarm in alarms) {
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
final plan = buildUpsertPlan(
|
||||
eventId: event.eventId,
|
||||
pending: pending,
|
||||
desired: alarms,
|
||||
);
|
||||
|
||||
for (final id in plan.idsToCancel) {
|
||||
await _plugin.cancel(id);
|
||||
}
|
||||
for (final alarm in plan.alarmsToSchedule) {
|
||||
await _scheduleAlarm(alarm);
|
||||
}
|
||||
}
|
||||
@@ -252,6 +265,7 @@ class ReminderSchedulerService {
|
||||
category: AndroidNotificationCategory.alarm,
|
||||
timeoutAfter: 15000,
|
||||
playSound: true,
|
||||
sound: RawResourceAndroidNotificationSound(_androidSoundResource),
|
||||
enableVibration: true,
|
||||
fullScreenIntent: false,
|
||||
ticker: 'calendar-reminder',
|
||||
@@ -259,6 +273,7 @@ class ReminderSchedulerService {
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
sound: _iosSoundFile,
|
||||
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||
);
|
||||
|
||||
@@ -344,6 +359,77 @@ class ReminderSchedulerService {
|
||||
return hash;
|
||||
}
|
||||
|
||||
static int notificationIdFor(String eventId, int fireTimeBucket) {
|
||||
return _notificationId(eventId, fireTimeBucket);
|
||||
}
|
||||
|
||||
static ReminderUpsertPlan buildUpsertPlan({
|
||||
required String eventId,
|
||||
required List<PendingNotificationRequest> pending,
|
||||
required List<ReminderAlarm> desired,
|
||||
}) {
|
||||
final desiredById = <int, ReminderAlarm>{
|
||||
for (final alarm in desired)
|
||||
_notificationId(alarm.eventId, alarm.fireTimeBucket): alarm,
|
||||
};
|
||||
|
||||
final existingPayloadById = <int, Map<String, dynamic>>{};
|
||||
for (final request in pending) {
|
||||
final raw = request.payload;
|
||||
if (raw == null || raw.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final payload = _parsePayload(raw);
|
||||
if (payload['eventId'] != eventId) {
|
||||
continue;
|
||||
}
|
||||
existingPayloadById[request.id] = payload;
|
||||
}
|
||||
|
||||
final idsToCancel = <int>[];
|
||||
for (final entry in existingPayloadById.entries) {
|
||||
final desiredAlarm = desiredById[entry.key];
|
||||
if (desiredAlarm == null) {
|
||||
idsToCancel.add(entry.key);
|
||||
continue;
|
||||
}
|
||||
if (!_matchesAlarmPayload(entry.value, desiredAlarm)) {
|
||||
idsToCancel.add(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
final alarmsToSchedule = <ReminderAlarm>[];
|
||||
for (final entry in desiredById.entries) {
|
||||
final existingPayload = existingPayloadById[entry.key];
|
||||
if (existingPayload == null ||
|
||||
!_matchesAlarmPayload(existingPayload, entry.value)) {
|
||||
alarmsToSchedule.add(entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
return ReminderUpsertPlan(
|
||||
idsToCancel: idsToCancel,
|
||||
alarmsToSchedule: alarmsToSchedule,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _matchesAlarmPayload(
|
||||
Map<String, dynamic> payload,
|
||||
ReminderAlarm alarm,
|
||||
) {
|
||||
return payload['eventId'] == alarm.eventId &&
|
||||
payload['title'] == alarm.title &&
|
||||
payload['startAt'] == alarm.startAt.toIso8601String() &&
|
||||
payload['endAt'] == alarm.endAt?.toIso8601String() &&
|
||||
payload['timezone'] == alarm.timezone &&
|
||||
payload['reminderMinutes'] == alarm.reminderMinutes &&
|
||||
payload['fireAt'] == alarm.fireAt.toIso8601String() &&
|
||||
payload['fireTimeBucket'] == alarm.fireTimeBucket &&
|
||||
payload['location'] == alarm.location &&
|
||||
payload['notes'] == alarm.notes &&
|
||||
payload['version'] == alarm.version;
|
||||
}
|
||||
|
||||
static tz.Location _safeLocation(String timezone) {
|
||||
try {
|
||||
return tz.getLocation(timezone);
|
||||
@@ -353,6 +439,16 @@ class ReminderSchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
class ReminderUpsertPlan {
|
||||
const ReminderUpsertPlan({
|
||||
required this.idsToCancel,
|
||||
required this.alarmsToSchedule,
|
||||
});
|
||||
|
||||
final List<int> idsToCancel;
|
||||
final List<ReminderAlarm> alarmsToSchedule;
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void _onBackgroundTap(NotificationResponse response) {
|
||||
debugPrint('Background reminder tap received: ${response.payload}');
|
||||
|
||||
Vendored
+9
-1
@@ -14,9 +14,17 @@ class CacheScope {
|
||||
}
|
||||
|
||||
static String token() {
|
||||
final value = maybeToken();
|
||||
if (value == null) {
|
||||
throw StateError('CacheScope not configured - user must be logged in');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String? maybeToken() {
|
||||
final raw = _provider?.call()?.trim();
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return 'anonymous';
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
Vendored
+3
-2
@@ -222,8 +222,9 @@ class CacheInvalidator {
|
||||
|
||||
void invalidate(String key) {
|
||||
final store = _store;
|
||||
if (store != null) {
|
||||
unawaited(store.remove(CacheScope.scopedKey(key)));
|
||||
final scope = CacheScope.maybeToken();
|
||||
if (store != null && scope != null) {
|
||||
unawaited(store.remove(CacheScope.scopedKey(key, scopeToken: scope)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-4
@@ -32,7 +32,10 @@ abstract class CachedRepository<T> {
|
||||
bool Function(T loaded)? shouldWriteLoaded,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final scopeToken = CacheScope.token();
|
||||
final scopeToken = CacheScope.maybeToken();
|
||||
if (scopeToken == null) {
|
||||
return loadFromRemote();
|
||||
}
|
||||
final scopedKey = CacheScope.scopedKey(key, scopeToken: scopeToken);
|
||||
|
||||
if (forceRefresh) {
|
||||
@@ -71,12 +74,22 @@ abstract class CachedRepository<T> {
|
||||
}
|
||||
|
||||
Future<CacheEntry<T>?> readCacheEntry(String key, {String? scopeToken}) {
|
||||
return _readDecodedEntry(CacheScope.scopedKey(key, scopeToken: scopeToken));
|
||||
final resolvedScope = _resolveScopeToken(scopeToken);
|
||||
if (resolvedScope == null) {
|
||||
return Future<CacheEntry<T>?>.value(null);
|
||||
}
|
||||
return _readDecodedEntry(
|
||||
CacheScope.scopedKey(key, scopeToken: resolvedScope),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> writeCacheEntry(String key, T value, {String? scopeToken}) {
|
||||
final resolvedScope = _resolveScopeToken(scopeToken);
|
||||
if (resolvedScope == null) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
return store.write<CacheEntry<Object?>>(
|
||||
_scopedKey(key, scopeToken: scopeToken),
|
||||
_scopedKey(key, scopeToken: resolvedScope),
|
||||
CacheEntry<Object?>(value: encodeValue(value), fetchedAt: now()),
|
||||
);
|
||||
}
|
||||
@@ -98,7 +111,11 @@ abstract class CachedRepository<T> {
|
||||
}
|
||||
|
||||
Future<void> removeCacheKey(String key, {String? scopeToken}) {
|
||||
return store.remove(CacheScope.scopedKey(key, scopeToken: scopeToken));
|
||||
final resolvedScope = _resolveScopeToken(scopeToken);
|
||||
if (resolvedScope == null) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
return store.remove(CacheScope.scopedKey(key, scopeToken: resolvedScope));
|
||||
}
|
||||
|
||||
void refreshInBackground({
|
||||
@@ -140,4 +157,12 @@ abstract class CachedRepository<T> {
|
||||
String _scopedKey(String key, {String? scopeToken}) {
|
||||
return CacheScope.scopedKey(key, scopeToken: scopeToken);
|
||||
}
|
||||
|
||||
String? _resolveScopeToken(String? scopeToken) {
|
||||
final scoped = scopeToken?.trim();
|
||||
if (scoped != null && scoped.isNotEmpty) {
|
||||
return scoped;
|
||||
}
|
||||
return CacheScope.maybeToken();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ class HomeScreen extends StatefulWidget {
|
||||
final VoiceRecorder? voiceRecorder;
|
||||
final Future<String> Function(String filePath)? onTranscribeAudio;
|
||||
final ChatBloc? chatBloc;
|
||||
final String? initialUserId;
|
||||
final bool autoLoadHistory;
|
||||
final List<XFile> initialSelectedImages;
|
||||
|
||||
@@ -60,6 +61,7 @@ class HomeScreen extends StatefulWidget {
|
||||
this.voiceRecorder,
|
||||
this.onTranscribeAudio,
|
||||
this.chatBloc,
|
||||
this.initialUserId,
|
||||
this.autoLoadHistory = true,
|
||||
this.initialSelectedImages = const [],
|
||||
});
|
||||
@@ -119,7 +121,10 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
duration: const Duration(milliseconds: _rippleDurationMs),
|
||||
);
|
||||
_selectedImages.addAll(widget.initialSelectedImages);
|
||||
if (widget.autoLoadHistory &&
|
||||
final initialUserId = widget.initialUserId?.trim();
|
||||
if (initialUserId != null && initialUserId.isNotEmpty) {
|
||||
unawaited(_chatBloc.switchUser(initialUserId));
|
||||
} else if (widget.autoLoadHistory &&
|
||||
_chatBloc.state.items.isEmpty &&
|
||||
!_chatBloc.state.isLoadingHistory) {
|
||||
_chatBloc.loadHistory();
|
||||
@@ -166,6 +171,20 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HomeScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final oldUserId = oldWidget.initialUserId?.trim();
|
||||
final newUserId = widget.initialUserId?.trim();
|
||||
if (oldUserId == newUserId) {
|
||||
return;
|
||||
}
|
||||
final normalized = (newUserId != null && newUserId.isNotEmpty)
|
||||
? newUserId
|
||||
: null;
|
||||
unawaited(_chatBloc.switchUser(normalized));
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
unawaited(_inboxSyncStore.refreshSnapshot());
|
||||
|
||||
@@ -42,14 +42,23 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
int newItemIndex,
|
||||
int newListIndex,
|
||||
) async {
|
||||
print(
|
||||
'DEBUG _onItemReorder: oldItemIndex=$oldItemIndex, oldListIndex=$oldListIndex, newItemIndex=$newItemIndex, newListIndex=$newListIndex',
|
||||
);
|
||||
if (_isReordering) {
|
||||
print('DEBUG _onItemReorder: early return - _isReordering=true');
|
||||
return;
|
||||
}
|
||||
|
||||
final sourceQuadrant = _quadrantByListIndex(oldListIndex);
|
||||
final targetQuadrant = _quadrantByListIndex(newListIndex);
|
||||
print(
|
||||
'DEBUG _onItemReorder: sourceQuadrant=$sourceQuadrant, targetQuadrant=$targetQuadrant',
|
||||
);
|
||||
final sourceItems = _sortedQuadrantTodos(sourceQuadrant);
|
||||
print('DEBUG _onItemReorder: sourceItems.length=${sourceItems.length}');
|
||||
if (oldItemIndex < 0 || oldItemIndex >= sourceItems.length) {
|
||||
print('DEBUG _onItemReorder: early return - index out of bounds');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,7 +75,9 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
targetQuadrant: targetQuadrant,
|
||||
insertIndex: newItemIndex,
|
||||
);
|
||||
print('DEBUG _onItemReorder: reordered=$reordered');
|
||||
if (reordered == null) {
|
||||
print('DEBUG _onItemReorder: early return - reordered is null');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,19 +128,27 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
required int targetQuadrant,
|
||||
required int insertIndex,
|
||||
}) {
|
||||
print(
|
||||
'DEBUG _reorderTodos: todoId=$todoId, sourceQuadrant=$sourceQuadrant, targetQuadrant=$targetQuadrant, insertIndex=$insertIndex',
|
||||
);
|
||||
final byId = {for (final todo in _todos) todo.id: todo};
|
||||
final moving = byId[todoId];
|
||||
print('DEBUG _reorderTodos: moving=$moving');
|
||||
if (moving == null) {
|
||||
print('DEBUG _reorderTodos: early return - moving is null');
|
||||
return null;
|
||||
}
|
||||
|
||||
final sourceList = _sortedQuadrantTodos(sourceQuadrant);
|
||||
print('DEBUG _reorderTodos: sourceList.length=${sourceList.length}');
|
||||
final targetList = sourceQuadrant == targetQuadrant
|
||||
? sourceList
|
||||
: _sortedQuadrantTodos(targetQuadrant);
|
||||
|
||||
final sourceIndex = sourceList.indexWhere((todo) => todo.id == todoId);
|
||||
print('DEBUG _reorderTodos: sourceIndex=$sourceIndex');
|
||||
if (sourceIndex == -1) {
|
||||
print('DEBUG _reorderTodos: early return - sourceIndex is -1');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -154,16 +173,33 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
}
|
||||
|
||||
final moved = extracted.copyWith(priority: targetQuadrant);
|
||||
print(
|
||||
'DEBUG _reorderTodos: moved.priority=${moved.priority}, moved.order=${moved.order}',
|
||||
);
|
||||
mutableTarget.insert(targetIndex, moved);
|
||||
|
||||
print('DEBUG _reorderTodos: mutableTarget.length=${mutableTarget.length}');
|
||||
for (var i = 0; i < mutableTarget.length; i++) {
|
||||
print(
|
||||
'DEBUG _reorderTodos: mutableTarget[$i] id=${mutableTarget[i].id}, priority=${mutableTarget[i].priority}, order=${mutableTarget[i].order}',
|
||||
);
|
||||
}
|
||||
|
||||
final updatedById = <String, TodoResponse>{};
|
||||
void reindex(List<TodoResponse> list, int priority) {
|
||||
print(
|
||||
'DEBUG _reorderTodos: reindex called with priority=$priority, list.length=${list.length}',
|
||||
);
|
||||
for (var index = 0; index < list.length; index += 1) {
|
||||
final current = list[index];
|
||||
final updated = current.copyWith(priority: priority, order: index);
|
||||
list[index] = updated;
|
||||
print(
|
||||
'DEBUG _reorderTodos: reindex item id=${current.id}, current.priority=${current.priority}, updated.priority=${updated.priority}, current.order=${current.order}, updated.order=${updated.order}',
|
||||
);
|
||||
if (current.priority != updated.priority ||
|
||||
current.order != updated.order) {
|
||||
print('DEBUG _reorderTodos: adding to updatedById id=${updated.id}');
|
||||
updatedById[updated.id] = updated;
|
||||
}
|
||||
}
|
||||
@@ -174,9 +210,12 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
} else {
|
||||
reindex(mutableSource, sourceQuadrant);
|
||||
reindex(mutableTarget, targetQuadrant);
|
||||
updatedById[moved.id] = moved;
|
||||
}
|
||||
|
||||
print('DEBUG _reorderTodos: updatedById.length=${updatedById.length}');
|
||||
if (updatedById.isEmpty) {
|
||||
print('DEBUG _reorderTodos: returning null because updatedById is empty');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -454,13 +493,19 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
onWillAcceptWithDetails: (details) => true,
|
||||
onAcceptWithDetails: (details) {
|
||||
final info = details.data;
|
||||
print(
|
||||
'DEBUG: onAccept - sourceQuadrant=${info.sourceQuadrant}, targetQuadrant=$value, sourceIndex=${info.sourceIndex}, todoId=${info.todoId}',
|
||||
);
|
||||
if (info.sourceQuadrant != value) {
|
||||
print('DEBUG: calling _onItemReorder');
|
||||
_onItemReorder(
|
||||
info.sourceIndex,
|
||||
_listIndexByQuadrant(info.sourceQuadrant),
|
||||
0,
|
||||
_listIndexByQuadrant(value),
|
||||
);
|
||||
} else {
|
||||
print('DEBUG: same quadrant, no reorder');
|
||||
}
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
|
||||
@@ -464,7 +464,7 @@
|
||||
"settingsClearCacheTitle": "Clear Local Cache",
|
||||
"settingsClearCacheMessage": "This will clear local cache and fetch fresh data. Continue?",
|
||||
"settingsClearCacheAction": "Clear",
|
||||
"settingsClearCacheSuccess": "Cache cleared. Refreshing data...",
|
||||
"settingsClearCacheSuccess": "Cache cleared",
|
||||
"settingsClearCacheFailed": "Failed to clear cache. Please try again later",
|
||||
"settingsJobDetailTitle": "Job Detail",
|
||||
"settingsJobCreatePageTitle": "Create Recurring Plan",
|
||||
|
||||
@@ -2171,7 +2171,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @settingsClearCacheSuccess.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'缓存已清理,正在重新拉取数据'**
|
||||
/// **'缓存已清理'**
|
||||
String get settingsClearCacheSuccess;
|
||||
|
||||
/// No description provided for @settingsClearCacheFailed.
|
||||
|
||||
@@ -1163,7 +1163,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get settingsClearCacheAction => 'Clear';
|
||||
|
||||
@override
|
||||
String get settingsClearCacheSuccess => 'Cache cleared. Refreshing data...';
|
||||
String get settingsClearCacheSuccess => 'Cache cleared';
|
||||
|
||||
@override
|
||||
String get settingsClearCacheFailed =>
|
||||
|
||||
@@ -1130,7 +1130,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get settingsClearCacheAction => '确认清理';
|
||||
|
||||
@override
|
||||
String get settingsClearCacheSuccess => '缓存已清理,正在重新拉取数据';
|
||||
String get settingsClearCacheSuccess => '缓存已清理';
|
||||
|
||||
@override
|
||||
String get settingsClearCacheFailed => '清理缓存失败,请稍后重试';
|
||||
|
||||
@@ -464,7 +464,7 @@
|
||||
"settingsClearCacheTitle": "清理本地缓存",
|
||||
"settingsClearCacheMessage": "将清理本地缓存并重新拉取最新数据,是否继续?",
|
||||
"settingsClearCacheAction": "确认清理",
|
||||
"settingsClearCacheSuccess": "缓存已清理,正在重新拉取数据",
|
||||
"settingsClearCacheSuccess": "缓存已清理",
|
||||
"settingsClearCacheFailed": "清理缓存失败,请稍后重试",
|
||||
"settingsJobDetailTitle": "任务详情",
|
||||
"settingsJobCreatePageTitle": "新建周期计划",
|
||||
|
||||
Reference in New Issue
Block a user