feat: 重构会话管理与提醒通知系统
This commit is contained in:
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
@@ -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": "新建周期计划",
|
||||
|
||||
+2
-1
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
Reference in New Issue
Block a user