feat: 重构会话管理与提醒通知系统

This commit is contained in:
qzl
2026-03-31 18:26:36 +08:00
parent a8c262e9c7
commit 9a231dae9e
31 changed files with 650 additions and 223 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -15,6 +15,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
D1E92316A42F4A26BE0A0E11 /* reminder_1.wav in Resources */ = {isa = PBXBuildFile; fileRef = D1E92316A42F4A26BE0A0E10 /* reminder_1.wav */; };
AF132A70E83FBE1B638C6F9F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90FE62ECAC858C9D6D8F555A /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */
@@ -61,6 +62,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D1E92316A42F4A26BE0A0E10 /* reminder_1.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = reminder_1.wav; sourceTree = "<group>"; };
990B7AE4995B1DA3D21F475A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
9B0AA8E2CDF83E1EE2B9D8B0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
@@ -155,6 +157,7 @@
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
D1E92316A42F4A26BE0A0E10 /* reminder_1.wav */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
@@ -259,6 +262,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D1E92316A42F4A26BE0A0E11 /* reminder_1.wav in Resources */,
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
Binary file not shown.
+35 -21
View File
@@ -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(
+4
View File
@@ -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),
);
+4 -3
View File
@@ -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}');
+9 -1
View File
@@ -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;
}
+3 -2
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 =>
+1 -1
View File
@@ -1130,7 +1130,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get settingsClearCacheAction => '确认清理';
@override
String get settingsClearCacheSuccess => '缓存已清理,正在重新拉取数据';
String get settingsClearCacheSuccess => '缓存已清理';
@override
String get settingsClearCacheFailed => '清理缓存失败,请稍后重试';
+1 -1
View File
@@ -464,7 +464,7 @@
"settingsClearCacheTitle": "清理本地缓存",
"settingsClearCacheMessage": "将清理本地缓存并重新拉取最新数据,是否继续?",
"settingsClearCacheAction": "确认清理",
"settingsClearCacheSuccess": "缓存已清理,正在重新拉取数据",
"settingsClearCacheSuccess": "缓存已清理",
"settingsClearCacheFailed": "清理缓存失败,请稍后重试",
"settingsJobDetailTitle": "任务详情",
"settingsJobCreatePageTitle": "新建周期计划",
+2 -1
View File
@@ -1,7 +1,7 @@
name: social_app
description: "Social App - A Flutter mobile application"
publish_to: 'none'
version: 0.1.2+4
version: 0.1.2+5
environment:
sdk: ^3.10.7
@@ -45,6 +45,7 @@ flutter:
assets:
- assets/images/
- assets/branding/
- assets/reminder_sound/
flutter_launcher_icons:
android: true
@@ -75,7 +75,11 @@ void main() {
});
test('home route screen is wrapped with ChatBloc provider', () {
final widget = buildHomeRouteScreen();
final widget = buildHomeRouteScreen(
const AuthAuthenticated(
user: AuthUser(id: 'u1', phone: '13800138000'),
),
);
expect(widget, isA<BlocProvider<ChatBloc>>());
});
@@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/app/services/session_scope_manager.dart';
import 'package:social_app/data/cache/cache_scope.dart';
import 'package:social_app/data/cache/cache_store.dart';
class _RecordingHybridCacheStore extends HybridCacheStore {
_RecordingHybridCacheStore()
: super(memory: MemoryCacheStore(), persistent: PersistentCacheStore());
final List<String> clearedPrefixes = <String>[];
@override
Future<void> clearByPrefix(String prefix) async {
clearedPrefixes.add(prefix);
}
}
void main() {
late _RecordingHybridCacheStore cacheStore;
late SessionScopeManager manager;
setUp(() {
cacheStore = _RecordingHybridCacheStore();
manager = SessionScopeManager(cacheStore: cacheStore);
CacheScope.resetProvider();
});
tearDown(() {
CacheScope.resetProvider();
});
test('activate configures stable user scope token', () async {
await manager.activate('u-1');
expect(CacheScope.token(), 'user:u-1');
expect(cacheStore.clearedPrefixes, isEmpty);
});
test('activate clears previous user cache when switching user', () async {
await manager.activate('u-1');
await manager.activate('u-2');
expect(cacheStore.clearedPrefixes, ['cache:user:u-1:']);
expect(CacheScope.token(), 'user:u-2');
});
test('activate does not clear cache when user is unchanged', () async {
await manager.activate('u-1');
await manager.activate('u-1');
expect(cacheStore.clearedPrefixes, isEmpty);
expect(CacheScope.token(), 'user:u-1');
});
test('clearActiveUserScope clears current user cache', () async {
await manager.activate('u-1');
await manager.clearActiveUserScope();
expect(cacheStore.clearedPrefixes, ['cache:user:u-1:']);
expect(() => CacheScope.token(), throwsStateError);
});
}
@@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/notification/models/reminder_alarm.dart';
import 'package:social_app/core/notification/services/reminder_scheduler_service.dart';
@@ -63,4 +66,125 @@ void main() {
expect(alarms, isEmpty);
});
test('buildUpsertPlan skips unchanged pending reminders', () {
final alarm = ReminderAlarm(
eventId: 'evt_5',
title: 'Sync',
startAt: DateTime(2026, 4, 1, 10, 0),
endAt: DateTime(2026, 4, 1, 11, 0),
timezone: 'Asia/Shanghai',
reminderMinutes: 15,
fireAt: DateTime(2026, 4, 1, 9, 45),
fireTimeBucket: 29114145,
location: 'Room 1',
notes: 'Bring notes',
);
final id = ReminderSchedulerService.notificationIdFor(
alarm.eventId,
alarm.fireTimeBucket,
);
final pending = [
PendingNotificationRequest(
id,
alarm.title,
'开始时间 10:00 · Room 1',
jsonEncode(alarm.toJson()),
),
];
final plan = ReminderSchedulerService.buildUpsertPlan(
eventId: alarm.eventId,
pending: pending,
desired: [alarm],
);
expect(plan.idsToCancel, isEmpty);
expect(plan.alarmsToSchedule, isEmpty);
});
test('buildUpsertPlan reschedules when payload changed', () {
final desired = ReminderAlarm(
eventId: 'evt_6',
title: 'Daily',
startAt: DateTime(2026, 4, 1, 8, 0),
endAt: DateTime(2026, 4, 1, 9, 0),
timezone: 'Asia/Shanghai',
reminderMinutes: 10,
fireAt: DateTime(2026, 4, 1, 7, 50),
fireTimeBucket: 29114030,
location: 'Desk',
notes: 'Updated',
);
final stale = ReminderAlarm(
eventId: desired.eventId,
title: desired.title,
startAt: desired.startAt,
endAt: desired.endAt,
timezone: desired.timezone,
reminderMinutes: desired.reminderMinutes,
fireAt: desired.fireAt,
fireTimeBucket: desired.fireTimeBucket,
location: desired.location,
notes: 'Old notes',
);
final id = ReminderSchedulerService.notificationIdFor(
desired.eventId,
desired.fireTimeBucket,
);
final plan = ReminderSchedulerService.buildUpsertPlan(
eventId: desired.eventId,
pending: [
PendingNotificationRequest(
id,
stale.title,
'开始时间 08:00 · Desk',
jsonEncode(stale.toJson()),
),
],
desired: [desired],
);
expect(plan.idsToCancel, [id]);
expect(plan.alarmsToSchedule.length, 1);
expect(plan.alarmsToSchedule.first.notes, 'Updated');
});
test('buildUpsertPlan cancels and rebuilds when payload is invalid', () {
final desired = ReminderAlarm(
eventId: 'evt_7',
title: 'Check-in',
startAt: DateTime(2026, 4, 2, 9, 0),
endAt: DateTime(2026, 4, 2, 9, 30),
timezone: 'Asia/Shanghai',
reminderMinutes: 10,
fireAt: DateTime(2026, 4, 2, 8, 50),
fireTimeBucket: 29115530,
location: 'Room A',
notes: 'fresh',
);
final id = ReminderSchedulerService.notificationIdFor(
desired.eventId,
desired.fireTimeBucket,
);
final plan = ReminderSchedulerService.buildUpsertPlan(
eventId: desired.eventId,
pending: [
PendingNotificationRequest(
id,
desired.title,
'开始时间 09:00 · Room A',
'{"eventId":"${desired.eventId}"}',
),
],
desired: [desired],
);
expect(plan.idsToCancel, [id]);
expect(plan.alarmsToSchedule.length, 1);
expect(plan.alarmsToSchedule.first.eventId, desired.eventId);
});
}
+26
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/data/cache/cache_policy.dart';
import 'package:social_app/data/cache/cache_scope.dart';
import 'package:social_app/data/cache/cached_repository.dart';
import 'package:social_app/data/cache/cache_store.dart';
@@ -29,6 +30,14 @@ class _IntCachedRepository extends CachedRepository<int> {
void main() {
group('CachedRepository', () {
setUp(() {
CacheScope.configureProvider(() => 'user:test');
});
tearDown(() {
CacheScope.resetProvider();
});
test('reads from cache after first load', () async {
final repo = _IntCachedRepository(
store: HybridCacheStore(
@@ -59,5 +68,22 @@ void main() {
expect(refreshed, 2);
expect(repo.loadCount, 2);
});
test('falls back to remote load when scope is unavailable', () async {
CacheScope.resetProvider();
final repo = _IntCachedRepository(
store: HybridCacheStore(
memory: MemoryCacheStore(),
persistent: PersistentCacheStore(),
),
);
final first = await repo.fetch();
final second = await repo.fetch();
expect(first, 1);
expect(second, 2);
expect(repo.loadCount, 2);
});
});
}
+16
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:social_app/data/cache/cache_scope.dart';
import 'package:social_app/data/cache/cache_store.dart';
void main() {
@@ -24,5 +25,20 @@ void main() {
final secondRead = await hybrid.read<String>('k');
expect(secondRead, 'v');
});
test('cache invalidator no-ops without configured scope', () async {
CacheScope.resetProvider();
final invalidator = CacheInvalidator(
store: HybridCacheStore(
memory: MemoryCacheStore(),
persistent: PersistentCacheStore(),
),
);
expect(
() => invalidator.invalidate('calendar:day:2026-03-31'),
returnsNormally,
);
});
});
}
@@ -82,7 +82,7 @@ class _FakeChatApi implements ChatApi {
void main() {
setUp(() {
CacheScope.configureProvider(() => null);
CacheScope.configureProvider(() => 'user:test');
});
tearDown(() {
+11
View File
@@ -43,6 +43,17 @@
"release_notes": "\u5347\u7ea7\u6d4b\u8bd5\u5305\uff1a\u9a8c\u8bc1\u8986\u76d6\u5b89\u88c5\u540e\u4fdd\u6301\u767b\u5f55\u6001",
"file_size": 61845996,
"sha256": "be9e725062e4cefef1486aed4a04fa5b7323ad1e11328a41552543d3c71e5060"
},
{
"platform": "android",
"channel": "release",
"version_name": "0.1.2",
"version_code": 5,
"min_supported_version_code": 5,
"file_name": "social-app-android-v0.1.2+5-release.apk",
"release_notes": "\u91cd\u6784 Reminder Notification \u7cfb\u7edf\u5e76\u66f4\u65b0\u5e94\u7528\u5305\u540d",
"file_size": 61813288,
"sha256": "899a3ae89f9931d9ef1bf5354eeae75d4b5a81ecce83f05a2820c95ff6771e55"
}
]
}
+103
View File
@@ -0,0 +1,103 @@
---
## 6. 日历提醒未使用自定义提示音
**文件**: `apps/lib/core/notification/services/reminder_scheduler_service.dart`
**问题描述**:
已添加 `apps/assets/reminder_sound/1.WAV` 自定义提示音文件,但代码中未使用,仍使用系统默认铃声:
```dart
// 当前代码
const androidDetails = AndroidNotificationDetails(
_channelId,
_channelName,
// ...
playSound: true, // 使用系统默认
// 没有 customSound 配置
);
```
**根因**:
添加了资源文件但未在通知配置中引用。
**建议修复**:
1.`pubspec.yaml` 添加 assets 路径:
```yaml
flutter:
assets:
- assets/reminder_sound/
```
2. 在通知配置中引用自定义铃声:
```dart
// Android
final androidDetails = AndroidNotificationDetails(
_channelId,
_channelName,
playSound: true,
sound: RawResourceAndroidNotificationSound('reminder_sound/1'),
// ...
);
// iOS
const iosDetails = DarwinNotificationDetails(
presentSound: true,
sound: 'reminder_sound_1.wav',
// ...
);
```
---
## 7. 通知调度缺少变化检测(重复重建)
**文件**: `apps/lib/core/notification/services/reminder_scheduler_service.dart`
**问题描述**:
每次获取日历事件都会调用 `upsertEventReminders`,内部执行 `cancelEventReminders` + 重建,即使事件没变化:
```dart
Future<void> upsertEventReminders(event) async {
await cancelEventReminders(event.eventId); // 总是先取消
for (alarm in buildAlarmsForEvent(event)) {
await _scheduleAlarm(alarm); // 总是重建
}
}
```
**影响**:
- 通知 ID 变化导致系统重新调度,浪费资源
- 用户频繁访问日历时产生不必要的操作
**建议修复**:
```dart
Future<void> upsertEventReminders(event) async {
final existing = await _getScheduledAlarm(event.eventId);
if (existing != null && _isSameAlarm(existing, event)) {
return; // 跳过,没变化
}
await cancelEventReminders(event.eventId);
for (alarm in buildAlarmsForEvent(event)) {
await _scheduleAlarm(alarm);
}
}
```
## 相关文件
- `apps/lib/app/app.dart`
- `apps/lib/data/cache/cache_scope.dart`
- `apps/lib/data/cache/cache_store.dart`
- `apps/lib/data/cache/cached_repository.dart`
- `apps/lib/core/inbox/inbox_sync_store.dart`
- `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
- `apps/lib/app/services/app_prewarm_orchestrator.dart`
- `apps/lib/core/notification/services/reminder_scheduler_service.dart`
- `apps/lib/core/notification/services/reminder_permission_service.dart`
- `apps/lib/core/notification/services/reminder_reconcile_service.dart`
- `apps/assets/reminder_sound/`
-106
View File
@@ -1,106 +0,0 @@
# 路由守卫逻辑分散
## 问题描述
当前路由守卫逻辑分散在两处,可能导致判断不一致:
1. `app_router.dart``redirect()` - 核心守卫逻辑
2. `LinksyApp``BlocListener` - 预留了位置但未使用
## 当前代码
```dart
// app_router.dart
GoRouter createAppRouter(AuthBloc authBloc) {
return GoRouter(
refreshListenable: GoRouterRefreshStream(authBloc.stream),
redirect: (context, state) {
final authState = authBloc.state;
final isAuthenticated = authState is AuthAuthenticated;
// ... 守卫判断逻辑
},
);
}
// app.dart (LinksyApp)
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
// Handle auth state changes if needed ← 预留但未使用
},
)
```
## 问题
| 问题 | 说明 |
|------|------|
| 逻辑分散 | 守卫在 `redirect()`,但 BlocListener 预留了位置 |
| 隐患 | 将来可能有人在两处都加逻辑,导致不一致 |
| 职责不清 | 到底是 redirect 管跳转,还是 BlocListener 管跳转 |
## 建议方案
**方案1:路由守卫集中在 redirect()(当前方案,保持但清理)**
```dart
// app_router.dart
GoRouter createAppRouter() {
return GoRouter(
refreshListenable: GoRouterRefreshStream(sl<AuthBloc>().stream),
redirect: (context, state) {
// 唯一的守卫逻辑
},
);
}
// LinksyApp - 只做副作用,不做路由跳转
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
// 埋点、Toast 等副作用
},
)
```
**方案2:路由守卫集中在 BlocListener**
```dart
// app_router.dart - 不再有 redirect
GoRouter createAppRouter() {
return GoRouter(
routes: [...],
);
}
// LinksyApp - 唯一的路由守卫入口
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
final router = GoRouter.of(context);
if (state is AuthUnauthenticated) {
if (!isPublicRoute(router.matchedLocation)) {
router.go(AppRoutes.authLogin);
}
} else if (state is AuthAuthenticated) {
if (router.matchedLocation == AppRoutes.authLogin) {
router.go(AppRoutes.homeMain);
}
}
},
)
```
## 收益
| 收益 | 说明 |
|------|------|
| 单一职责 | 路由跳转只在一处判断 |
| 可维护 | 将来不会有人误在另一处加逻辑 |
| 清晰 | 开发者知道去哪改守卫逻辑 |
## 涉及文件
- `apps/lib/app/app.dart`
- `apps/lib/app/router/app_router.dart`
## 状态
- [ ] 待修复
@@ -1,74 +0,0 @@
# Agent Calendar/Inbox Stability Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 修复 agent 日历分享失败、对话后日历不刷新、邀请信息不完整,并新增设置页一键清理本地缓存后强制重新拉取。
**Architecture:** 后端补齐 `calendar_share` 依赖注入与邀请 payload 字段,确保工具链与 API 路由行为一致。前端在 Chat 工具结果事件上增加日历刷新钩子,并在设置页提供缓存清理入口,复用既有 prewarm 机制触发重新拉取。协议文档同步更新 inbox 邀请结构,避免前后端契约漂移。
**Tech Stack:** FastAPI + SQLAlchemy + AgentScope tools, Flutter + CachedRepository + SharedPreferences cache.
---
### Task 1: 修复 calendar_share 在 Agent 工具链中的依赖缺失
**Files:**
- Modify: `backend/src/core/agentscope/tools/utils/calendar_domain.py`
**Steps:**
1.`create_schedule_service` 注入 `SQLAlchemyUserRepository`
2. 保持路由层与工具层对 `ScheduleItemService` 的依赖一致。
3. 回归验证 `calendar_share` 不再因为 actor lookup 依赖缺失而失败。
### Task 2: 扩充 calendar invite payload(邀请人 + 时间 + 描述)
**Files:**
- Modify: `backend/src/v1/schedule_items/service.py`
- Modify: `docs/protocols/models/inbox-messages.md`
- Test: `backend/tests/unit/v1/schedule_items/test_share.py`
**Steps:**
1.`share` 中构建邀请消息时写入 `actor.phone`
2.`item` 中写入 `description/start_at/end_at/timezone`
3. 更新协议文档 `CalendarInviteContent`
4. 补充/更新单测断言新增字段。
### Task 3: 对话工具成功后触发日历缓存刷新钩子
**Files:**
- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart`
- Modify: `apps/lib/app/di/injection.dart`
- Test: `apps/test/features/chat/presentation/bloc/chat_bloc_test.dart`
**Steps:**
1.`ChatBloc` 增加可注入回调 `onCalendarMutated`
2.`ToolCallResultEvent` 中识别 `calendar_write` 成功/部分成功并触发回调。
3. DI 中将回调绑定为 `CalendarRepository.getDayEvents/getMonthEvents(forceRefresh: true)`
4. 添加回归测试验证回调触发。
### Task 4: 设置页新增“清理缓存”并触发重新拉取
**Files:**
- Modify: `apps/lib/data/cache/cache_store.dart`
- Modify: `apps/lib/features/settings/presentation/screens/settings_screen.dart`
- Modify: `apps/lib/l10n/app_zh.arb`
- Modify: `apps/lib/l10n/app_en.arb`
**Steps:**
1.`HybridCacheStore` 增加按前缀清理能力(`cache:`)。
2. 设置页在“检查更新”下新增“清理缓存”。
3. 点击后清理缓存并触发 prewarm + inbox 快照刷新。
4. 同步中英文文案并生成本地化代码。
### Task 5: 验证
**Commands:**
- `uv run pytest backend/tests/unit/v1/schedule_items/test_share.py backend/tests/unit/core/agentscope/test_calendar_tools.py -k "share or calendar_share"`
- `flutter test test/features/chat/presentation/bloc/chat_bloc_test.dart`
- `flutter analyze`
**Expected:**
- 后端分享链路测试通过,新增邀请字段存在。
- ChatBloc 回归测试通过,`calendar_write` 成功时触发刷新回调。
- Flutter 静态检查通过,无新增错误。