diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ea0d351d..a2c313b3 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -65,7 +65,7 @@ android { // (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` // flag during build. - versionCode = 6 + versionCode = 7 versionName = flutter.versionName manifestPlaceholders["busymaxMsalRedirectHost"] = applicationId!! manifestPlaceholders["busymaxMsalSignatureHash"] = msalSignatureHash diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml index abf894d5..8fb34de8 100644 --- a/android/app/src/main/res/xml/backup_rules.xml +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -2,5 +2,8 @@ + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml index 404dc1d3..776900fd 100644 --- a/android/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -2,10 +2,16 @@ + + + + + + diff --git a/docs/android_release.md b/docs/android_release.md index 1ea8265f..5572822a 100644 --- a/docs/android_release.md +++ b/docs/android_release.md @@ -9,6 +9,7 @@ For v0.3.0 the public version is `0.3.0` and the package code is `5`, advancing the v0.2.4 source and retained test-signed artifact code `4`. During preparation on 2026-10-11, the owner confirmed `4` is the highest distributed base code. For v0.3.1, code `6` is confirmed available by the owner on 2026-10-11. +For v0.3.2, the package code advances to `7`. For subsequent releases, verify the distribution ledger and account for ABI offsets if split APKs were distributed before choosing the next code. diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 96ebcbaa..c9f853c0 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -57,7 +57,7 @@ explicit revision, and incorporate fetched `origin/main`. ```bash tool/release/build_snap_local.sh \ - --version 0.3.1 \ + --version 0.3.2 \ --revision "$(git rev-parse HEAD)" \ --google-oauth /path/to/google-desktop-download-directory \ --local-config config/release.local.json \ @@ -72,8 +72,8 @@ retained for inspection; choose a new path for another attempt. No reset, forced worktree removal, or destructive cleanup is used. The command checks all five version files before compiling; it never rewrites -them. v0.3.1 uses Android code **6**, confirmed available by the owner on -2026-10-11. It resolves the lockfile, generates/rechecks localization and Drift, +them. v0.3.2 uses Android code **7**. It resolves the lockfile, +generates/rechecks localization and Drift, runs formatting, analysis, platform boundaries, full Flutter/tool/native tests, and compiles a fresh configured bundle. It verifies the actual managed Google configuration in `libapp.so`, runs canonical Snapcraft packaging with @@ -97,11 +97,11 @@ owner's desktop or uploaded automatically. Use the exact printed path throughout. In its artifacts directory: ```bash -sha256sum --check busymax_0.3.1_amd64.snap.sha256 -unsquashfs -cat busymax_0.3.1_amd64.snap meta/snap.yaml +sha256sum --check busymax_0.3.2_amd64.snap.sha256 +unsquashfs -cat busymax_0.3.2_amd64.snap meta/snap.yaml ``` -Confirm `0.3.1`, `core24`, `devel`, `strict`, the BusyMax launcher, its autostart +Confirm `0.3.2`, `core24`, `devel`, `strict`, the BusyMax launcher, its autostart entry, desktop file, icon and AppStream history. Investigate library warnings using [the library investigation record](snap_runtime_libraries.md); do not add broad linter suppression. The VM evidence must show no unresolved runtime ELF @@ -113,7 +113,7 @@ preferences, contacts settings, conflicts and pending offline work. Back up that test state. Quit BusyMax and upgrade using the exact candidate: ```bash -sudo snap install --dangerous /absolute/path/busymax_0.3.1_amd64.snap +sudo snap install --dangerous /absolute/path/busymax_0.3.2_amd64.snap snap connections busymax snap run busymax ``` @@ -141,7 +141,7 @@ only the verified checksum. The build command never publishes: ```bash snapcraft login snapcraft whoami -snapcraft upload --release=beta /absolute/path/busymax_0.3.1_amd64.snap +snapcraft upload --release=beta /absolute/path/busymax_0.3.2_amd64.snap snapcraft revisions busymax --arch amd64 snapcraft status busymax --arch amd64 ``` diff --git a/docs/desktop_oauth_release_checklist.md b/docs/desktop_oauth_release_checklist.md index 9d4b27a5..2dd7219a 100644 --- a/docs/desktop_oauth_release_checklist.md +++ b/docs/desktop_oauth_release_checklist.md @@ -1,6 +1,6 @@ # Desktop OAuth release prerequisites -v0.3.1 uses the owner's new Google Desktop client as the BusyMax-managed +v0.3.2 uses the owner's new Google Desktop client as the BusyMax-managed registration for locally built Linux releases. The download has a valid `installed` object and project ID `busymax`. Its actual fields are read by the [local release command](beta_snap_release.md); no credential JSON is committed. diff --git a/docs/development.md b/docs/development.md index 241c0d32..f975c21d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -177,8 +177,8 @@ their regression coverage. ## Release branches -Start `release/v0.3.1` from current `main`, commit reviewed changes, and open it -into `main` with title `release/v0.3.1`. Fetch before final review and incorporate +Start `release/v0.3.2` from current `main`, commit reviewed changes, and open it +into `main` with title `release/v0.3.2`. Fetch before final review and incorporate later `origin/main` commits with a normal merge. The existing `tool/check_release_pull_request.py` validates the actual PR head, release metadata and current-main ancestry. A synthetic PR merge does not establish diff --git a/docs/icalendar_data_model.md b/docs/icalendar_data_model.md index cd1fa9bb..9804d31d 100644 --- a/docs/icalendar_data_model.md +++ b/docs/icalendar_data_model.md @@ -45,6 +45,10 @@ Status, percentage, and completion time change as one state transition. Completing a parent completes open descendants; reopening a descendant reopens a closed ancestor. Completing a recurring master records its completed instance and advances the master when another occurrence exists. +When an UNTIL rule is exhausted, the wire master remains intact for Nextcloud +compatibility. Its matching completed history determines the local completed +state. Explicit reopening updates that same history component; completing it +again retains its recurrence identity. The editor supports the daily, weekly, monthly, and yearly recurrence subset represented by its controls, including supported intervals, selectors, counts, diff --git a/docs/snap_runtime_libraries.md b/docs/snap_runtime_libraries.md index 06c9b6c1..2025889f 100644 --- a/docs/snap_runtime_libraries.md +++ b/docs/snap_runtime_libraries.md @@ -3,8 +3,9 @@ Checked 2026-10-11 with Snapcraft 9.1.4, core24 revision 2124, GNOME 46-2404 revision 168 and mesa-2404 revision 1839. The installed BusyMax v0.3.0 revision x3 was used only to establish runtime provenance; it is not -v0.3.1 acceptance evidence. The local release command repeats resolution in a -fresh Ubuntu 24.04 VM against the exact new package and its checksum. +acceptance evidence for a later release. The local release command repeats +resolution in a fresh Ubuntu 24.04 VM against the exact new package and its +checksum. Inside `snap run --shell busymax`, `ldd` on the runner, Flutter engine and libgweather resolved the reported libraries as follows: diff --git a/integration_test/accounts_settings_linux_test.dart b/integration_test/accounts_settings_linux_test.dart new file mode 100644 index 00000000..61535df6 --- /dev/null +++ b/integration_test/accounts_settings_linux_test.dart @@ -0,0 +1,1087 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show PointerDeviceKind; + +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/app/linux/linux_window_host.dart'; +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/dav/dav_errors.dart'; +import 'package:busymax/src/dav/storage/dav_settings_repository.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; +import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; +import 'package:busymax/src/features/accounts/domain/account_connection_state.dart'; +import 'package:busymax/src/demo/demo_profile.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/calendar_providers/calendar_colors.dart'; +import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:busymax/src/platform/gtk_header_icon_service.dart'; +import 'package:busymax/src/platform/gtk_window_preferences_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_shared_calendar_address.dart'; +import 'package:drift/drift.dart' show Value, Variable; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:yaru/yaru.dart'; +import 'package:ubuntu_localizations/ubuntu_localizations.dart'; + +import '../test/support/contacts_fixture.dart'; +import '../test/support/dav_contacts_fixture.dart' as carddav; +import '../test/support/desktop_registration_config.dart'; + +// Explicit opt-in. Every account, provider response, secret and database below +// is synthetic; the owner's profile, keyring and providers are never opened. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final output = Platform.environment['BUSYMAX_ACCEPTANCE_ROOT']; + testWidgets( + 'native Accounts supports multiple identities, themes, narrow windows, keyboard and safe confirmation', + (tester) async { + expect(Platform.environment['XDG_DATA_HOME'], '$output/data'); + final semantics = tester.ensureSemantics(); + await tester.runAsync(YaruWindowTitleBar.ensureInitialized); + final f = ContactsFixture(AppDatabase.memoryForTests(), realDav: true); + final davContacts = carddav.DavFixture(); + f.respondDav = davContacts.respond; + final secrets = InMemorySecretStore(); + final icons = GtkHeaderIconService(); + await tester.runAsync(icons.initialize); + final windowPreferences = await tester.runAsync( + () => const GtkWindowPreferencesService().load(), + ); + await tester.runAsync(() async { + for (final provider in [ + BusyProvider.google, + BusyProvider.microsoft, + BusyProvider.nextcloud, + ]) { + for (var number = 1; number <= 2; number++) { + final id = '${provider.name}:qa-$number'; + await f.accounts.upsertSignedInAccount( + id: id, + provider: provider, + authority: provider == BusyProvider.nextcloud + ? 'https://cloud$number.example.test/team' + : null, + providerAccountId: provider == BusyProvider.nextcloud + ? 'qa-user' + : 'qa-$number', + displayName: + 'Quality Assurance $number — a deliberately long synthetic account identity', + email: provider == BusyProvider.nextcloud + ? null + : 'qa-$number@example.test', + grantedScopes: '', + ); + if (provider != BusyProvider.nextcloud) { + await f.database.customStatement( + 'INSERT INTO domain_sync_schedules(account_id,domain,last_successful_pull_utc) VALUES (?, ?, ?)', + [id, 'tasks', '2026-10-19T12:00:00Z'], + ); + } + if (provider == BusyProvider.nextcloud) { + await secrets.saveCredential( + id, + NextcloudSecretRecord( + canonicalServer: Uri.parse( + 'https://cloud$number.example.test/team', + ), + loginName: 'qa-user', + appPassword: 'synthetic-only', + ), + ); + await f.database + .into(f.database.davAccountServices) + .insert( + DavAccountServicesCompanion.insert( + accountId: id, + canonicalServiceUri: + 'https://cloud$number.example.test/team/remote.php/dav/', + canonicalOrigin: 'https://cloud$number.example.test', + discoveredAtUtc: '2026-10-19T12:00:00Z', + ), + ); + if (number == 1) { + for (final mask in [1, 2]) { + await f.database + .into(f.database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: '$id:single-$mask', + accountId: id, + hrefKey: '/team/single-$mask/', + requestUri: + 'https://cloud$number.example.test/team/single-$mask/', + displayName: mask == 1 + ? 'Personal appointments' + : 'Personal tasks', + resourceTypesJson: const Value( + '["{DAV:}collection","{urn:ietf:params:xml:ns:caldav}calendar"]', + ), + supportedComponentMask: Value(mask), + eventProjectionEnabled: Value(mask == 1), + taskProjectionEnabled: Value(mask == 2), + currentUserPrivilegesJson: const Value( + '["{DAV:}read","{DAV:}write-properties"]', + ), + createdAtUtc: '2026-10-19T12:00:00Z', + updatedAtUtc: '2026-10-19T12:00:00Z', + ), + ); + } + } + await f.database + .into(f.database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: '$id:collection', + accountId: id, + hrefKey: '/team/calendar/', + requestUri: + 'https://cloud$number.example.test/team/calendar/', + displayName: 'Team events and tasks $number', + resourceTypesJson: const Value( + '["{DAV:}collection","{urn:ietf:params:xml:ns:caldav}calendar"]', + ), + supportedComponentMask: const Value(3), + eventProjectionEnabled: const Value(true), + taskProjectionEnabled: const Value(true), + currentUserPrivilegesJson: const Value( + '["{DAV:}read","{DAV:}write-properties"]', + ), + createdAtUtc: '2026-10-19T12:00:00Z', + updatedAtUtc: '2026-10-19T12:00:00Z', + ), + ); + } else { + await CalendarRepository(database: f.database).upsertSource( + accountId: id, + source: CalendarSourceDto( + provider: provider, + providerCalendarId: provider == BusyProvider.microsoft + ? MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner-$number@example.test', + graphCalendarId: 'qa', + ).sourceCalendarId + : 'qa-$number', + summary: provider == BusyProvider.google + ? 'QA calendar $number' + : 'Shared primary QA calendar $number', + primaryCalendar: + provider == BusyProvider.google && number == 1, + readOnly: provider == BusyProvider.microsoft, + accessRole: provider == BusyProvider.microsoft + ? 'unavailable' + : 'owner', + rawJson: const {'hidden': false}, + ), + ); + if (provider == BusyProvider.google && number == 2) { + await CalendarRepository(database: f.database).setSourceColor( + CalendarRepository.sourceId( + accountId: id, + provider: provider, + providerCalendarId: 'qa-$number', + ), + calendarColorChoices(provider).last, + ); + await CalendarRepository(database: f.database) + .setSourceProviderHidden( + CalendarRepository.sourceId( + accountId: id, + provider: provider, + providerCalendarId: 'qa-$number', + ), + true, + ); + } + } + } + } + f.davSecrets['independent-qa'] = jsonEncode({ + 'version': 1, + 'server': 'https://contacts.example.test', + 'username': 'independent-qa', + 'password': 'synthetic-only', + 'readOnly': true, + }); + await f.store.write( + (tx) => tx.putAccount( + const ContactAccount( + id: 'independent-qa', + provider: ContactProviderKind.carddav, + displayName: 'Independent QA address book', + subject: 'independent-qa', + enabled: false, + grantedScopes: {'carddav:read'}, + ), + ), + ); + }); + final calendar = CalendarRepository(database: f.database); + final revocations = []; + final appClient = MockClient((request) async { + expect(request.method, 'DELETE'); + expect( + request.url.toString(), + 'https://cloud2.example.test/team/ocs/v2.php/core/apppassword', + ); + revocations.add(request); + return http.Response('', 204); + }); + final davSettings = DavSettingsRepository(database: f.database); + Completer? inventoryGate, syncEntered, syncGate; + var googleDenied = true; + final patches = []; + final googleClient = GoogleCalendarApiClient( + baseUri: Uri.parse('https://www.googleapis.com'), + httpClient: MockClient((request) async { + expect(request.method, 'PATCH'); + expect(request.url.path, '/calendar/v3/users/me/calendarList/qa-2'); + patches.add(request); + return googleDenied + ? http.Response('{"error":{"code":403}}', 403) + : http.Response( + jsonEncode({ + 'id': 'qa-2', + 'summary': 'QA calendar 2', + 'hidden': jsonDecode(request.body)['hidden'], + 'backgroundColor': '#9a9cff', + 'foregroundColor': '#000000', + 'colorId': '1', + }), + 200, + ); + }), + ); + final googleEngine = CalendarSyncEngine( + database: f.database, + client: googleClient, + accountId: 'google:qa-2', + ); + final googleRecovery = PendingOpResolutionService( + database: f.database, + accountId: 'google:qa-2', + syncTasks: () async => + fail('Calendar recovery must not dispatch Tasks'), + syncCalendar: googleEngine.dispatchPendingWrites, + ); + late final ProviderContainer container; + container = ProviderContainer( + overrides: [ + databaseProvider.overrideWithValue(f.database), + baseHttpClientProvider.overrideWithValue(appClient), + gtkHeaderIconServiceProvider.overrideWithValue(icons), + initialGtkWindowPreferencesProvider.overrideWithValue( + windowPreferences, + ), + secretStoreProvider.overrideWithValue(secrets), + busyMaxContactsStoreProvider.overrideWithValue(f.store), + busyMaxContactsControllerProvider.overrideWithValue(f.controller), + davSettingsRepositoryProvider.overrideWithValue(davSettings), + davCollectionsStreamProvider.overrideWith((ref) async* { + await inventoryGate?.future; + yield* davSettings.watchCollections(); + }), + pendingOpResolutionServiceForAccountProvider.overrideWith((ref, id) { + expect(id, 'google:qa-2'); + return googleRecovery; + }), + localSettingsStoreProvider.overrideWithValue( + InMemoryLocalSettingsStore(), + ), + buildConfigProvider.overrideWithValue(syntheticDesktopConfig()), + selectedAccountIdProvider.overrideWith((ref) => 'google:qa-1'), + signedInSyncRunnerProvider.overrideWithValue((id, _) async { + await container.read(accountSyncActivityProvider).run( + id, + 'dav', + () async { + syncEntered?.complete(); + await syncGate?.future; + if (id.startsWith('nextcloud:')) { + await f.accounts.setConnectionState( + id, + AccountConnectionState.connected, + ); + await f.database.customUpdate( + 'UPDATE dav_account_services SET last_discovery_error_code = NULL WHERE account_id = ?', + variables: [Variable(id)], + updates: {f.database.davAccountServices}, + ); + } + }, + ); + }), + ], + ); + final font = await tester.runAsync( + () => const GtkFontService().getGtkFont(), + ); + final textScale = ValueNotifier(1); + for (final brightness in [Brightness.light, Brightness.dark]) { + await tester.runAsync( + () => const GtkThemeService().setPreferDark( + brightness == Brightness.dark, + ), + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + debugShowCheckedModeBanner: false, + locale: const Locale('en'), + localizationsDelegates: const [ + ...AppLocalizations.localizationsDelegates, + ...GlobalUbuntuLocalizations.delegates, + ], + supportedLocales: AppLocalizations.supportedLocales, + theme: BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: YaruColors.orange, + gtkFontFamily: font?.family, + gtkFontSize: font?.size, + ), + builder: (context, child) => ValueListenableBuilder( + valueListenable: textScale, + child: GtkHeaderIconScope( + service: icons, + child: LinuxWindowHost(child: child!), + ), + builder: (context, scale, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + ), + home: const SettingsScreen(initialPage: SettingsPage.accounts), + ), + ), + ); + await _resize(tester, 1200, 1000); + await tester.pumpAndSettle(); + final theme = brightness.name; + for (final provider in ['google', 'microsoft', 'nextcloud']) { + for (final number in [1, 2]) { + expect( + find.byKey(ValueKey('account-entry-$provider:qa-$number')), + findsOneWidget, + ); + } + } + const additions = { + 'Add Google account', + 'Add Microsoft account', + 'Add Nextcloud account', + 'Add Apple iCloud Calendar account', + }; + for (final label in additions) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('Personal appointments'), findsNothing); + tester + .widget(find.byType(SingleChildScrollView)) + .controller! + .jumpTo(0); + await _capture(tester, '$output/$theme-overview.png'); + final origin = find.byKey( + const ValueKey('account-entry-nextcloud:qa-1'), + ); + await tester.ensureVisible(origin); + final originFocus = tester + .widget( + find.descendant( + of: origin, + matching: find.byType(BusyMaxActionRow), + ), + ) + .focusNode!; + final overviewScroll = tester + .widget(find.byType(SingleChildScrollView)) + .controller!; + final originOffset = overviewScroll.offset; + await tester.tap(origin); + await tester.pumpAndSettle(); + expect( + find.textContaining('qa-user · https://cloud1.example.test/team'), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('remove-account-nextcloud:qa-2')), + findsNothing, + ); + await _capture(tester, '$output/$theme-nextcloud-healthy.png'); + await _tap(tester, find.text('Personal appointments')); + expect( + (await tester.runAsync( + () => davSettings.collectionById('nextcloud:qa-1:single-1'), + ))!.eventsSelected, + isFalse, + ); + await _tap(tester, find.text('Team events and tasks 1')); + expect( + (await tester.runAsync( + () => davSettings.collectionById('nextcloud:qa-1:collection'), + ))!.tasksSelected, + isTrue, + ); + await _tap( + tester, + find.byKey( + const ValueKey('dav-tasks-toggle-nextcloud:qa-1:collection'), + ), + ); + expect( + (await tester.runAsync( + () => davSettings.collectionById('nextcloud:qa-1:collection'), + ))!.tasksSelected, + isFalse, + ); + expect( + (await tester.runAsync( + () => davSettings.collectionById('nextcloud:qa-2:collection'), + ))!.tasksSelected, + isTrue, + ); + await _tap(tester, find.text('Team events and tasks 1')); + await tester.runAsync(() async { + await davSettings.setEventsSelected('nextcloud:qa-1:single-1', true); + await davSettings.setTasksSelected('nextcloud:qa-1:collection', true); + }); + inventoryGate = Completer(); + container.invalidate(davCollectionsStreamProvider); + await tester.pump(); + await tester.pump(); + expect(find.text('Loading collections…'), findsWidgets); + await _capture(tester, '$output/$theme-loading.png', settle: false); + inventoryGate.complete(); + inventoryGate = null; + await tester.pumpAndSettle(); + await _networkFailure(tester, container, f); + expect(find.text('Could not reach the service'), findsOneWidget); + expect(find.textContaining('DavException'), findsNothing); + expect(find.textContaining('synthetic-correlation'), findsNothing); + await _capture(tester, '$output/$theme-nextcloud-network-error.png'); + await _resize(tester, 640, 700); + await _capture(tester, '$output/$theme-narrow.png'); + await _resize(tester, 640, 500); + await _capture(tester, '$output/$theme-short.png'); + textScale.value = 1.6; + await tester.pumpAndSettle(); + await _capture(tester, '$output/$theme-enlarged.png'); + textScale.value = 1; + await tester.pumpAndSettle(); + await _resize(tester, 1200, 1000); + syncEntered = Completer(); + syncGate = Completer(); + await tester.tap( + find.byKey(const ValueKey('account-recovery-nextcloud:qa-1')), + ); + await tester.pump(); + await tester.runAsync(() => syncEntered!.future); + await _back(tester); + expect(originFocus.hasFocus, isTrue); + expect(overviewScroll.offset, closeTo(originOffset, 1)); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('account-detail-nextcloud:qa-1')), + findsOneWidget, + ); + await _back(tester); + await _open(tester, 'nextcloud:qa-2'); + syncGate.complete(); + await tester.pumpAndSettle(); + syncGate = syncEntered = null; + expect( + find.byKey(const ValueKey('account-detail-nextcloud:qa-2')), + findsOneWidget, + ); + final secondCollection = (await tester.runAsync( + () => + (f.database.select(f.database.davCollections) + ..where((r) => r.id.equals('nextcloud:qa-2:collection'))) + .getSingle(), + ))!; + await tester.runAsync( + () => (f.database.delete( + f.database.davCollections, + )..where((r) => r.id.equals(secondCollection.id))).go(), + ); + await tester.pumpAndSettle(); + expect(find.text('No collections found.'), findsOneWidget); + await tester.runAsync(() async { + await f.database + .into(f.database.davCollections) + .insert(secondCollection); + await f.accounts.setConnectionState( + 'nextcloud:qa-2', + AccountConnectionState.reauthenticationRequired, + ); + }); + await tester.pumpAndSettle(); + await _tap( + tester, + find.byKey(const ValueKey('account-recovery-nextcloud:qa-2')), + ); + await _tap(tester, find.text('Cancel').last); + await tester.runAsync( + () => f.accounts.setConnectionState( + 'nextcloud:qa-2', + AccountConnectionState.connected, + ), + ); + await tester.pumpAndSettle(); + await _back(tester); + + await _open(tester, 'google:qa-2'); + final sourceId = CalendarRepository.sourceId( + accountId: 'google:qa-2', + provider: BusyProvider.google, + providerCalendarId: 'qa-2', + ); + await tester.runAsync(() async { + await calendar.setSourceColor( + sourceId, + calendarColorChoices(BusyProvider.google).last, + ); + await calendar.setSourceProviderHidden(sourceId, true); + }); + await tester.pumpAndSettle(); + expect(find.text('Google visibility change pending'), findsOneWidget); + await _tap(tester, find.text('QA calendar 2')); + googleDenied = true; + await tester.runAsync(googleEngine.dispatchPendingWrites); + await tester.pumpAndSettle(); + final retry = find.byKey( + ValueKey('settings-calendar-visibility-retry-$sourceId'), + ); + final discard = find.byKey( + ValueKey('settings-calendar-visibility-discard-$sourceId'), + ); + expect(tester.getSemantics(retry).label, contains('QA calendar 2')); + await _capture(tester, '$output/$theme-google-visibility-recovery.png'); + await _tap(tester, discard); + expect( + find.textContaining( + '• Calendar color\n• Show in Google’s calendar list', + ), + findsOneWidget, + ); + expect(find.textContaining('Google Tasks'), findsNothing); + await _capture(tester, '$output/$theme-google-discard.png'); + await _tap(tester, find.text('Cancel').last); + googleDenied = false; + final beforeRetry = patches.length; + await _tap(tester, retry); + await _waitForCalendarQueue(tester, f.database); + expect(patches.length, beforeRetry + 1); + expect( + (await tester.runAsync( + () => f.database.select(f.database.pendingOps).get(), + ))!, + isEmpty, + ); + await tester.runAsync(() async { + await calendar.setSourceProviderHidden(sourceId, false); + await googleEngine.dispatchPendingWrites(); + await calendar.setSourceColor( + sourceId, + calendarColorChoices(BusyProvider.google).first, + ); + await calendar.setSourceProviderHidden(sourceId, true); + googleDenied = true; + await googleEngine.dispatchPendingWrites(); + }); + await tester.pumpAndSettle(); + await _tap(tester, discard); + await _tap(tester, find.text('Discard').last); + await _waitForCalendarQueue(tester, f.database); + expect( + (await tester.runAsync( + () => f.database.select(f.database.pendingOps).get(), + ))!, + isEmpty, + ); + final local = find.byKey( + ValueKey('settings-calendar-schedule-$sourceId'), + ); + expect(tester.widget(local).onChanged, isNotNull); + await _tap(tester, local); + expect( + (await tester.runAsync( + () => calendar.watchSourcesForAccounts(['google:qa-2']).first, + ))!.single.selected, + isFalse, + ); + await tester.runAsync(() => calendar.setSourceSelected(sourceId, true)); + await _back(tester); + + await _open(tester, 'google:qa-1'); + await _tap(tester, find.text('QA calendar 1')); + final primaryId = CalendarRepository.sourceId( + accountId: 'google:qa-1', + provider: BusyProvider.google, + providerCalendarId: 'qa-1', + ); + expect( + tester + .widget( + find.byKey(ValueKey('settings-calendar-provider-$primaryId')), + ) + .onChanged, + isNull, + ); + expect( + find.text('Google’s primary calendar stays in its calendar list.'), + findsOneWidget, + ); + if (brightness == Brightness.light) { + f.consentSubject = 'qa-1'; + await tester.runAsync( + () => f.controller.enableLinkedContacts('google:qa-1'), + ); + await tester.runAsync( + () => f.controller.synchronizeAccount('contacts:google:qa-1'), + ); + await tester.pumpAndSettle(); + } + final source = (await tester.runAsync(f.controller.sourceSettings))! + .firstWhere((s) => s.source.accountId == 'contacts:google:qa-1'); + final sourceSwitch = find.byKey( + ValueKey('contact-source-${source.source.key}'), + ); + await _tap(tester, sourceSwitch); + expect( + (await tester.runAsync(f.controller.sourceSettings))! + .firstWhere((s) => s.source.key == source.source.key) + .enabled, + isFalse, + ); + await _tap(tester, sourceSwitch); + await _expand( + tester, + find.byKey(const ValueKey('account-advanced-google:qa-1')), + ); + await _capture(tester, '$output/$theme-google-advanced.png'); + await _back(tester); + + await _open(tester, 'microsoft:qa-1'); + await tester.runAsync(() async { + await f.accounts.setConnectionState( + 'microsoft:qa-1', + AccountConnectionState.temporarilyUnavailable, + ); + try { + await container.read(accountSyncActivityProvider).run( + 'microsoft:qa-1', + 'calendar', + () async { + throw const DavException( + kind: DavErrorKind.network, + code: 'DavNetworkFailure', + safeMessage: 'Synthetic diagnostic', + ); + }, + ); + } on DavException { + /* Calendar failed independently of Tasks. */ + } + await container + .read(accountSyncActivityProvider) + .run('microsoft:qa-1', 'tasks', () async {}); + }); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('account-domain-feedback-calendar-microsoft:qa-1'), + ), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('account-domain-feedback-tasks-microsoft:qa-1'), + ), + findsNothing, + ); + expect( + find.textContaining('Access unavailable; showing cached data.'), + findsWidgets, + ); + await _capture(tester, '$output/$theme-microsoft-unavailable.png'); + // Opening a new shared calendar retains the existing connection-state + // eligibility rule. Keep the failed Calendar outcome visible while + // restoring the synthetic account's connection for this dialog check. + await tester.runAsync( + () => f.accounts.setConnectionState( + 'microsoft:qa-1', + AccountConnectionState.connected, + ), + ); + await tester.pumpAndSettle(); + await _tap(tester, find.text('Open shared primary calendar')); + expect( + find.textContaining('Opens the specified owner’s primary calendar.'), + findsOneWidget, + ); + await _tap(tester, find.text('Cancel').last); + await _back(tester); + + await _tap( + tester, + find.byKey(const ValueKey('contact-account-entry-independent-qa')), + ); + expect(find.text('https://contacts.example.test'), findsOneWidget); + if (brightness == Brightness.light) { + final contactReady = Completer(); + final subscription = f.controller.changes.listen((_) async { + final sources = await f.controller.sourceSettings(); + if (!contactReady.isCompleted && + sources.any((s) => s.source.accountId == 'independent-qa') && + f.davSecrets['independent-qa']!.contains( + 'synthetic-replacement-only', + )) { + contactReady.complete(); + } + }); + await _tap( + tester, + find.byKey( + const ValueKey('contact-account-recovery-independent-qa'), + ), + ); + await tester.enterText( + find.byType(TextField).last, + 'synthetic-replacement-only', + ); + await tester.pumpAndSettle(); + final reconnectButton = find.descendant( + of: find.byType(BusyMaxPromptDialog), + matching: find.widgetWithText( + ElevatedButton, + 'Reconnect this account', + ), + ); + expect( + tester.widget(reconnectButton).onPressed, + isNotNull, + ); + await _tap(tester, reconnectButton); + expect(find.byType(BusyMaxPromptDialog), findsNothing); + await tester.runAsync( + () => contactReady.future.timeout(const Duration(seconds: 10)), + ); + await subscription.cancel(); + await tester.runAsync( + () => f.controller.synchronizeAccount('independent-qa'), + ); + await tester.pumpAndSettle(); + expect( + f.davSecrets['independent-qa'], + contains('synthetic-replacement-only'), + ); + expect(davContacts.requests, isNotEmpty); + expect( + davContacts.requests.where( + (r) => ['PUT', 'DELETE', 'PROPPATCH'].contains(r.method), + ), + isEmpty, + ); + } + await _capture(tester, '$output/$theme-independent-contacts.png'); + final contactRemoval = find.byKey( + const ValueKey('remove-contacts-only-independent-qa'), + ); + await tester.ensureVisible(contactRemoval); + FocusManager.instance.primaryFocus?.unfocus(); + final pointer = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await pointer.addPointer(location: Offset.zero); + await _capture(tester, '$output/$theme-independent-remove-normal.png'); + await pointer.moveTo(tester.getCenter(contactRemoval)); + await _capture(tester, '$output/$theme-independent-remove-hover.png'); + await pointer.moveTo(Offset.zero); + final removeLabel = find.descendant( + of: contactRemoval, + matching: find.text('Remove'), + ); + Focus.of(tester.element(removeLabel)).requestFocus(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect(Focus.of(tester.element(removeLabel)).hasFocus, isTrue); + await _capture(tester, '$output/$theme-independent-remove-focus.png'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + // The removal operation remains busy while confirmation is open. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); + await _tap(tester, find.text('Cancel')); + expect( + await tester.runAsync( + () => f.store.read((tx) => tx.account('independent-qa')), + ), + isNotNull, + ); + await pointer.removePointer(); + await _back(tester); + await _open(tester, 'nextcloud:qa-2'); + await tester.runAsync( + () => f.database.customStatement( + "CREATE TEMP TRIGGER reject_qa_removal BEFORE DELETE ON accounts WHEN OLD.id = 'nextcloud:qa-2' BEGIN SELECT RAISE(ABORT, 'synthetic deletion failure'); END", + ), + ); + await _tap( + tester, + find.byKey(const ValueKey('remove-account-nextcloud:qa-2')), + ); + await _capture(tester, '$output/$theme-remove-confirmation.png'); + await _tap(tester, find.byKey(const Key('confirm-account-removal'))); + await tester.runAsync(() async { + // Join the service's guarded removal while local deletion is blocked + // by the fixture trigger, then wait for restoration to finish. + await expectLater( + container + .read(davAccountOnboardingServiceProvider) + .removeAccount('nextcloud:qa-2'), + throwsA(isNotNull), + ); + }); + await tester.pumpAndSettle(); + expect( + find.text('Could not finish removing the account. Try again.'), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('account-detail-nextcloud:qa-2')), + findsOneWidget, + ); + expect( + await tester.runAsync(() => f.accounts.accountById('nextcloud:qa-2')), + isNotNull, + ); + expect( + await tester.runAsync(() => secrets.readCredential('nextcloud:qa-2')), + isNotNull, + ); + await tester.runAsync( + () => f.database.customStatement('DROP TRIGGER reject_qa_removal'), + ); + await _back(tester); + await tester.ensureVisible(find.text('Add Nextcloud account')); + Focus.of(tester.element(find.text('Add Nextcloud account'))) + .requestFocus(); + await tester.pumpAndSettle(); + final visited = {'Add Nextcloud account'}; + for ( + var step = 0; + step < 28 && !visited.containsAll(additions); + step++ + ) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + final title = FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType() + ?.title; + if (title != null) visited.add(title); + } + expect(visited, containsAll(additions)); + await _capture(tester, '$output/$theme-keyboard-focus.png'); + expect(tester.takeException(), isNull); + } + await _open(tester, 'nextcloud:qa-2'); + await _tap( + tester, + find.byKey(const ValueKey('remove-account-nextcloud:qa-2')), + ); + await _tap(tester, find.byKey(const Key('confirm-account-removal'))); + await tester.runAsync( + () => + (f.database.select(f.database.accounts) + ..where((r) => r.id.equals('nextcloud:qa-2'))) + .watch() + .firstWhere((rows) => rows.isEmpty) + .timeout(const Duration(seconds: 10)), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('account-detail-nextcloud:qa-2')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('account-entry-nextcloud:qa-2')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('account-entry-nextcloud:qa-1')), + findsOneWidget, + ); + expect( + await tester.runAsync(() => secrets.readCredential('nextcloud:qa-2')), + isNull, + ); + expect( + (await tester.runAsync(f.accounts.listVisibleAccounts))!, + hasLength(5), + ); + expect(revocations, hasLength(1)); + expect(FocusManager.instance.primaryFocus?.context, isNotNull); + expect( + FocusManager.instance.primaryFocus!.debugLabel, + startsWith('account-entry-'), + ); + expect( + FocusManager.instance.primaryFocus!.debugLabel, + isNot(contains('nextcloud:qa-2')), + ); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + container.dispose(); + icons.dispose(); + appClient.close(); + semantics.dispose(); + textScale.dispose(); + await tester.runAsync(f.close); + }, + skip: output == null, + ); +} + +Future _resize(WidgetTester tester, int width, int height) async { + await tester.runAsync(() async { + final result = await Process.run('/usr/bin/python3', [ + '-c', + r'''import ctypes, re, subprocess, sys +x = ctypes.CDLL('libX11.so.6') +x.XOpenDisplay.argtypes = [ctypes.c_char_p] +x.XOpenDisplay.restype = ctypes.c_void_p +x.XResizeWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_uint, ctypes.c_uint] +x.XFlush.argtypes = [ctypes.c_void_p] +x.XCloseDisplay.argtypes = [ctypes.c_void_p] +tree = subprocess.check_output(['/usr/bin/xwininfo', '-root', '-tree'], text=True) +windows = re.findall(r'^\s*(0x[0-9a-f]+) "BusyMax":.*? (\d+)x(\d+)[+-]', tree, re.MULTILINE) +main = [item for item in windows if int(item[1]) > 300 and int(item[2]) > 300] +assert len(main) == 1, 'Expected one BusyMax window on the isolated display' +connection = x.XOpenDisplay(None) +x.XResizeWindow(connection, int(main[0][0], 16), int(sys.argv[1]), int(sys.argv[2])) +x.XFlush(connection) +x.XCloseDisplay(connection) +''', + '$width', + '$height', + ]); + expect(result.exitCode, 0, reason: result.stderr.toString()); + }); + await tester.pumpAndSettle(); +} + +Future _capture( + WidgetTester tester, + String path, { + bool settle = true, +}) async { + if (settle) await tester.pumpAndSettle(); + await tester.runAsync(() async { + // Capture the isolated application window, including its modal overlays. + final result = await Process.run('/usr/bin/python3', [ + '-c', + r'''import sys, gi, re, subprocess +gi.require_version('Gdk', '3.0') +from gi.repository import Gdk +tree = subprocess.check_output(['/usr/bin/xwininfo', '-root', '-tree'], text=True) +windows = re.findall(r'^\s*(0x[0-9a-f]+) "BusyMax":.*? (\d+)x(\d+)[+-]', tree, re.MULTILINE) +main = [item for item in windows if int(item[1]) > 300 and int(item[2]) > 300] +assert len(main) == 1, 'Expected one isolated BusyMax application window' +info = subprocess.check_output(['/usr/bin/xwininfo', '-id', main[0][0]], text=True) +def field(name): + return int(re.search(name + r':\s*(-?\d+)', info).group(1)) +window = Gdk.get_default_root_window() +pixbuf = Gdk.pixbuf_get_from_window(window, field('Absolute upper-left X'), field('Absolute upper-left Y'), field('Width'), field('Height')) +pixbuf.savev(sys.argv[1], 'png', [], []) +''', + path, + ]); + expect(result.exitCode, 0, reason: result.stderr.toString()); + }); +} + +Future _tap(WidgetTester tester, Finder target) async { + await tester.ensureVisible(target); + await tester.tap(target); + await tester.pumpAndSettle(); +} + +Future _waitForCalendarQueue( + WidgetTester tester, + AppDatabase database, +) async { + await tester.runAsync( + () => database + .select(database.pendingOps) + .watch() + .firstWhere((rows) => rows.isEmpty) + .timeout(const Duration(seconds: 10)), + ); + await tester.pumpAndSettle(); +} + +Future _open(WidgetTester tester, String id) => + _tap(tester, find.byKey(ValueKey('account-entry-$id'))); + +Future _back(WidgetTester tester) => + _tap(tester, find.byKey(const ValueKey('settings-header-back-button'))); + +Future _expand(WidgetTester tester, Finder target) => _tap( + tester, + find.descendant(of: target, matching: find.text('Advanced')).first, +); + +Future _networkFailure( + WidgetTester tester, + ProviderContainer container, + ContactsFixture f, +) async { + await tester.runAsync(() async { + try { + await container.read(accountSyncActivityProvider).run( + 'nextcloud:qa-1', + 'dav', + () async { + throw const DavException( + kind: DavErrorKind.network, + code: 'DavNetworkFailure', + safeMessage: 'Synthetic private diagnostic', + correlationId: 'synthetic-correlation', + ); + }, + ); + } on DavException { + /* The activity store retains the typed failure. */ + } + await f.accounts.setConnectionState( + 'nextcloud:qa-1', + AccountConnectionState.temporarilyUnavailable, + ); + await f.database.customUpdate( + "UPDATE dav_account_services SET last_discovery_error_code = 'DavNetworkFailure' WHERE account_id = 'nextcloud:qa-1'", + updates: {f.database.davAccountServices}, + ); + }); + await tester.pumpAndSettle(); +} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index e3ef90d0..67686f95 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "تعذّر تحميل حسابات جهات الاتصال", "contactsTitle": "جهات الاتصال", "contactsLinkedDescription": "استخدم جهات الاتصال المخزنة مؤقتًا من هذا الحساب عند إضافة مدعوين إلى الأحداث.", "contactsEnableSuggestions": "تفعيل اقتراحات جهات الاتصال", @@ -1092,7 +1093,7 @@ "windowMinimize": "تصغير", "windowMaximize": "تكبير", "windowRestore": "استعادة", - "openSharedCalendar": "فتح تقويم مشترك", + "openSharedCalendar": "فتح تقويم أساسي مشترك", "calendarOwnerEmail": "البريد الإلكتروني لمالك التقويم", "addCalendarShare": "إضافة وصول", "calendarShareFreeBusy": "التوفر فقط", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "تعذر التحقق من هوية اتصال Google القديم هذا. تم الاحتفاظ بالتقويمات والمهام والتغييرات المعلقة. أضف اتصال Google منفصلًا للمزامنة بأمان. ستبقى التغييرات المعلقة مع الحساب الأصلي.", - "addSeparateGoogleAccount": "إضافة اتصال Google منفصل" + "addSeparateGoogleAccount": "إضافة اتصال Google منفصل", + "seriesPatternChangeConfirmation": "قد تؤدي هذه التغييرات في نمط التكرار إلى إضافة تواريخ أو إزالتها.\n\nالحالي: \u2068{before}\u2069\nالمقترح: \u2068{after}\u2069\n\nهل تريد حفظ هذا النمط؟", + "connectedAccounts": "الحسابات المتصلة", + "contactsIndependentAccounts": "حسابات جهات اتصال مستقلة", + "contactsNextcloudReuse": "تعيد اقتراحات جهات الاتصال استخدام بيانات اعتماد هذا الحساب عبر تكامل جهات الاتصال للقراءة فقط.", + "contactsRetrySync": "إعادة محاولة مزامنة جهات الاتصال", + "contactsPreferenceFailed": "تعذّر حفظ تفضيل مصدر جهات الاتصال.", + "contactsSuggestionsDisabled": "اقتراحات جهات الاتصال معطّلة", + "contactsSyncFailed": "تعذّرت مزامنة جهات الاتصال. أعد المحاولة أو أعد توصيل الحساب إذا انتهى التفويض.", + "registrationRecoveryDescription": "التسجيل المحفوظ أو ارتباط بيانات اعتماده غير متاح أو مرفوض. استبدل تسجيل هذا الحساب صراحةً لاستعادته؛ تُحفظ البيانات الحالية.", + "revalidateAccount": "إعادة التحقق من الاتصال", + "openSharedPrimaryDescription": "يفتح التقويم الأساسي للمالك المحدد. تُكتشف التقاويم المشتركة الأخرى عبر مزامنة الحساب المعتادة.", + "showInBusyMax": "إظهار في BusyMax", + "showInGoogleCalendarList": "إظهار في قائمة تقاويم Google", + "googleVisibilityHelp": "اختيار BusyMax محلي. يغيّر ظهور Google قائمتك في Google؛ لا يمكن عرض التقاويم المخفية في BusyMax.", + "primaryCalendarVisibilityExplanation": "يبقى تقويم Google الأساسي في قائمته.", + "accountsLoading": "جارٍ تحميل الحسابات…", + "accountsLoadFailed": "تعذّر تحميل الحسابات. أعد فتح الحسابات.", + "collectionsLoading": "جارٍ تحميل المجموعات…", + "collectionsLoadFailed": "تعذّر تحميل المجموعات. تُحفظ البيانات المخزنة مؤقتًا؛ أعد محاولة المزامنة.", + "collectionsNotDiscovered": "لم يكتمل الاكتشاف. حدّث هذا الحساب أو أعد توصيله.", + "collectionsEmpty": "لم يُعثر على مجموعات.", + "accountReadyToSync": "جاهز للمزامنة", + "accountSyncProblem": "تحتاج المزامنة إلى معالجة", + "accountServiceUnreachable": "تعذّر الوصول إلى الخدمة", + "accountSyncDetails": "تفاصيل المزامنة", + "accountReconnectStatus": "يلزم إعادة الاتصال", + "accountRegistrationStatus": "يحتاج تسجيل التطبيق إلى معالجة", + "collectionsUnavailable": "المجموعات غير متاحة", + "accountContactSuggestions": "اقتراح مدعوين من جهات اتصال هذا الحساب", + "accountLoadingSignInSettings": "جارٍ تحميل إعدادات تسجيل الدخول…", + "contactsNoAddressBooks": "لا توجد دفاتر عناوين متاحة", + "contactsSourcesUnavailable": "تعذّر تحميل دفاتر العناوين", + "syncNoRecord": "لم يُسجّل وقت مزامنة ناجحة", + "cachedSyncError": "فشلت المزامنة؛ قد تكون البيانات المخزنة مؤقتًا قديمة.", + "calendarRemotePending": "تغيير ظهور Google قيد الانتظار", + "calendarRemoteFailed": "فشل تغيير الظهور في Google. تظهر آخر حالة مؤكدة في Google. ما زال BusyMax يتبع التغيير المعلّق. أعد المحاولة أو تجاهل التغيير.", + "discardCalendarSettingsConfirmation": "هل تريد تجاهل التغييرات المعلّقة على «⁨{calendar}⁩»؟\n\nستعود هذه الإعدادات إلى قيمها السابقة:\n⁨{settings}⁩\n\nسيُحتفظ باختيار التقويم في BusyMax. لن تُرسل أي تغييرات إلى Google.", + "sharedCalendarUnavailable": "الوصول غير متاح؛ تُعرض البيانات المخزنة مؤقتًا. أعد المزامنة أو أزل التقويم من قائمتك.", + "invalidCollectionColor": "أدخل لونًا سداسيًا عشريًا من ست أو ثماني خانات يبدأ بـ #.", + "invalidCollectionOrder": "أدخل عددًا صحيحًا.", + "invalidCollectionTimezone": "أدخل مستند منطقة زمنية صالحًا للتقويم يحتوي على VTIMEZONE وTZID.", + "invalidCollectionValue": "صحّح هذه القيمة قبل الحفظ.", + "accountAlreadyConnected": "هذا الحساب متصل بالفعل. استخدم إجراء إعادة توصيله.", + "nextcloudRemovalAuthorizationDescription": "سيحاول BusyMax أيضًا إلغاء كلمة مرور تطبيق Nextcloud لهذا الحساب. قد تنجح الإزالة المحلية حتى إذا فشل الإلغاء." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cd2eec61..aab2c2ba 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Kontaktkonten konnten nicht geladen werden", "contactsTitle": "Kontakte", "contactsLinkedDescription": "Beim Hinzufügen von Veranstaltungsgästen zwischengespeicherte Kontakte dieses Kontos verwenden.", "contactsEnableSuggestions": "Kontaktvorschläge aktivieren", @@ -1155,7 +1156,7 @@ "windowMinimize": "Minimieren", "windowMaximize": "Maximieren", "windowRestore": "Wiederherstellen", - "openSharedCalendar": "Freigegebenen Kalender öffnen", + "openSharedCalendar": "Freigegebenen Hauptkalender öffnen", "calendarOwnerEmail": "E-Mail-Adresse des Kalenderbesitzers", "addCalendarShare": "Zugriff hinzufügen", "calendarShareFreeBusy": "Nur Frei/Gebucht", @@ -1359,5 +1360,49 @@ } }, "googleIdentityUnverified": "Die Identität dieser älteren Google-Verbindung konnte nicht bestätigt werden. Kalender, Aufgaben und ausstehende Änderungen bleiben erhalten. Fügen Sie eine separate Google-Verbindung hinzu. Ausstehende Änderungen bleiben beim ursprünglichen Konto.", - "addSeparateGoogleAccount": "Separate Google-Verbindung hinzufügen" + "addSeparateGoogleAccount": "Separate Google-Verbindung hinzufügen", + "seriesPatternChangeConfirmation": "Dies ändert das Wiederholungsmuster und kann Termine hinzufügen oder entfernen.\n\nAktuell: {before}\nVorschlag: {after}\n\nDieses Muster speichern?", + "connectedAccounts": "Verbundene Konten", + "contactsIndependentAccounts": "Unabhängige Kontaktkonten", + "contactsNextcloudReuse": "Kontaktvorschläge verwenden die vorhandenen Zugangsdaten dieses Kontos über die schreibgeschützte Kontaktintegration.", + "contactsRetrySync": "Kontaktsynchronisierung wiederholen", + "contactsPreferenceFailed": "Die Kontaktquelleneinstellung konnte nicht gespeichert werden.", + "contactsSuggestionsDisabled": "Kontaktvorschläge deaktiviert", + "contactsSyncFailed": "Kontakte konnten nicht synchronisiert werden. Erneut versuchen oder bei abgelaufener Autorisierung das Konto neu verbinden.", + "registrationRecoveryDescription": "Die gespeicherte Registrierung oder ihre Zugangsdatenbindung fehlt oder wurde abgelehnt. Ersetzen Sie die Registrierung dieses Kontos ausdrücklich, um den Zugriff wiederherzustellen. Vorhandene Daten bleiben erhalten.", + "revalidateAccount": "Verbindung erneut prüfen", + "openSharedPrimaryDescription": "Öffnet den Hauptkalender des angegebenen Besitzers. Andere freigegebene Kalender werden bei der normalen Kontosynchronisierung gefunden.", + "showInBusyMax": "In BusyMax anzeigen", + "showInGoogleCalendarList": "In Googles Kalenderliste anzeigen", + "googleVisibilityHelp": "Die BusyMax-Auswahl gilt lokal. Die Google-Sichtbarkeit ändert Ihre Kalenderliste bei Google; ausgeblendete Kalender können in BusyMax nicht angezeigt werden.", + "primaryCalendarVisibilityExplanation": "Der Google-Hauptkalender bleibt in der Kalenderliste.", + "accountsLoading": "Konten werden geladen…", + "accountsLoadFailed": "Konten konnten nicht geladen werden. Öffnen Sie Konten erneut.", + "collectionsLoading": "Sammlungen werden geladen…", + "collectionsLoadFailed": "Sammlungen konnten nicht geladen werden. Zwischengespeicherte Daten bleiben erhalten. Synchronisierung erneut versuchen.", + "collectionsNotDiscovered": "Die Erkennung ist noch nicht abgeschlossen. Konto aktualisieren oder neu verbinden.", + "collectionsEmpty": "Keine Sammlungen gefunden.", + "accountReadyToSync": "Bereit zur Synchronisierung", + "accountSyncProblem": "Synchronisierung benötigt Aufmerksamkeit", + "accountServiceUnreachable": "Dienst nicht erreichbar", + "accountSyncDetails": "Synchronisierungsdetails", + "accountReconnectStatus": "Erneute Verbindung erforderlich", + "accountRegistrationStatus": "App-Registrierung benötigt Aufmerksamkeit", + "collectionsUnavailable": "Sammlungen nicht verfügbar", + "accountContactSuggestions": "Gäste aus den Kontakten dieses Kontos vorschlagen", + "accountLoadingSignInSettings": "Anmeldeeinstellungen werden geladen…", + "contactsNoAddressBooks": "Keine Adressbücher verfügbar", + "contactsSourcesUnavailable": "Adressbücher konnten nicht geladen werden", + "syncNoRecord": "Kein erfolgreicher Synchronisierungszeitpunkt erfasst", + "cachedSyncError": "Synchronisierung fehlgeschlagen; zwischengespeicherte Daten können veraltet sein.", + "calendarRemotePending": "Google-Sichtbarkeitsänderung ausstehend", + "calendarRemoteFailed": "Die Sichtbarkeitsänderung bei Google ist fehlgeschlagen. Der letzte bestätigte Google-Status wird angezeigt. BusyMax folgt weiterhin der ausstehenden Änderung. Erneut versuchen oder verwerfen.", + "discardCalendarSettingsConfirmation": "Ausstehende Änderungen an „{calendar}“ verwerfen?\n\nDiese Einstellungen werden auf ihre vorherigen Werte zurückgesetzt:\n{settings}\n\nDeine Kalenderauswahl in BusyMax bleibt erhalten. Es werden keine Änderungen an Google gesendet.", + "sharedCalendarUnavailable": "Zugriff nicht verfügbar; zwischengespeicherte Daten werden angezeigt. Synchronisierung wiederholen oder aus der Kalenderliste entfernen.", + "invalidCollectionColor": "Geben Sie eine sechs- oder achtstellige Hexadezimalfarbe mit # ein.", + "invalidCollectionOrder": "Geben Sie eine ganze Zahl ein.", + "invalidCollectionTimezone": "Geben Sie ein gültiges Kalenderzeitzonendokument mit VTIMEZONE und TZID ein.", + "invalidCollectionValue": "Korrigieren Sie diesen Wert vor dem Speichern.", + "accountAlreadyConnected": "Dieses Konto ist bereits verbunden. Verwenden Sie die Aktion zum erneuten Verbinden.", + "nextcloudRemovalAuthorizationDescription": "BusyMax versucht außerdem, das Nextcloud-App-Passwort dieses Kontos zu widerrufen. Die lokale Entfernung kann auch bei fehlgeschlagenem Widerruf erfolgreich sein." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 26dc79a4..81f6686f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Could not load contact accounts", "launchAtLoginManagedExternally": "BusyMax was launched by your desktop's startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.", "timeFormat": "Time format", "firstDayOfWeek": "First day of the week", @@ -1772,7 +1773,7 @@ "searchFiltersAction": "Filters", "searchNoSources": "No search sources selected", "searchClearText": "Clear text", - "openSharedCalendar": "Open shared calendar", + "openSharedCalendar": "Open shared primary calendar", "manageCalendarSharing": "Manage calendar sharing", "shareRecipientEmail": "Recipient email", "shareRole": "Access role", @@ -1991,5 +1992,71 @@ } }, "googleIdentityUnverified": "The identity of this older Google connection could not be verified. Its calendars, tasks and pending changes are preserved. Add a separate Google connection to synchronize safely. Pending changes will remain with the original account.", - "addSeparateGoogleAccount": "Add a separate Google connection" + "addSeparateGoogleAccount": "Add a separate Google connection", + "seriesPatternChangeConfirmation": "This changes the recurrence pattern and may add or remove dates.\n\nCurrent: {before}\nProposed: {after}\n\nSave this pattern?", + "@seriesPatternChangeConfirmation": { + "description": "Explicit approval of an ordinary series date edit that changes recurrence semantics. Both patterns include their termination condition.", + "placeholders": { + "before": { + "type": "String" + }, + "after": { + "type": "String" + } + } + }, + "connectedAccounts": "Connected accounts", + "contactsIndependentAccounts": "Independent contact accounts", + "contactsNextcloudReuse": "Contact suggestions reuse this account’s existing credential through the read-only contacts integration.", + "contactsRetrySync": "Retry contacts sync", + "contactsPreferenceFailed": "Could not save contact source preference", + "contactsSuggestionsDisabled": "Contact suggestions disabled", + "contactsSyncFailed": "Contacts could not sync. Retry, or reconnect if authorization has expired.", + "registrationRecoveryDescription": "The saved registration or its credential binding is unavailable or rejected. Explicitly replace the registration for this account to recover; existing data is preserved.", + "revalidateAccount": "Revalidate connection", + "openSharedPrimaryDescription": "Opens the specified owner’s primary calendar. Other shared calendars are discovered through ordinary account synchronization.", + "showInBusyMax": "Show in BusyMax", + "showInGoogleCalendarList": "Show in Google’s calendar list", + "googleVisibilityHelp": "BusyMax selection is local. Google visibility changes your calendar list on Google; hidden calendars cannot be displayed in BusyMax.", + "primaryCalendarVisibilityExplanation": "Google’s primary calendar stays in its calendar list.", + "accountsLoading": "Loading accounts…", + "accountsLoadFailed": "Accounts could not be loaded. Retry opening Accounts.", + "collectionsLoading": "Loading collections…", + "collectionsLoadFailed": "Collections could not be loaded. Cached data is retained; retry synchronization.", + "collectionsNotDiscovered": "Discovery has not completed. Refresh or reconnect this account.", + "collectionsEmpty": "No collections found.", + "accountReadyToSync": "Ready to sync", + "accountSyncProblem": "Synchronization needs attention", + "accountServiceUnreachable": "Could not reach the service", + "accountSyncDetails": "Synchronization details", + "accountReconnectStatus": "Reconnect required", + "accountRegistrationStatus": "Registration needs attention", + "collectionsUnavailable": "Collections unavailable", + "accountContactSuggestions": "Suggest guests from this account’s contacts", + "accountLoadingSignInSettings": "Loading sign-in settings…", + "contactsNoAddressBooks": "No address books available", + "contactsSourcesUnavailable": "Could not load address books", + "syncNoRecord": "No successful sync time recorded", + "cachedSyncError": "Sync failed; cached data may be out of date.", + "calendarRemotePending": "Google visibility change pending", + "calendarRemoteFailed": "Google visibility change failed. Google’s last confirmed state is shown. BusyMax still follows the pending change. Retry or discard it.", + "discardCalendarSettingsConfirmation": "Discard the pending changes to “{calendar}”?\n\nThese settings will return to their previous values:\n{settings}\n\nYour BusyMax calendar selection is kept. No changes will be sent to Google.", + "@discardCalendarSettingsConfirmation": { + "description": "Confirms discarding the entire queued Google calendar settings patch. calendar is the calendar display name; settings is a localized, newline-separated bulleted list of every affected setting. No provider request is sent, and the local calendar selection is preserved.", + "placeholders": { + "calendar": { + "type": "String" + }, + "settings": { + "type": "String" + } + } + }, + "sharedCalendarUnavailable": "Access unavailable; showing cached data. Retry synchronization or remove from your calendar list.", + "invalidCollectionColor": "Enter a six- or eight-digit hexadecimal color, starting with #.", + "invalidCollectionOrder": "Enter a whole number.", + "invalidCollectionTimezone": "Enter a valid calendar timezone document containing VTIMEZONE and TZID.", + "invalidCollectionValue": "Correct this value before saving.", + "accountAlreadyConnected": "This account is already connected. Use its Reconnect action.", + "nextcloudRemovalAuthorizationDescription": "BusyMax will also try to revoke this account’s Nextcloud app password. Local removal can succeed even if that revocation fails." } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3a8c93d6..fa2cb231 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "No se pudieron cargar las cuentas de contactos", "contactsTitle": "Contactos", "contactsLinkedDescription": "Usa los contactos almacenados de esta cuenta al añadir invitados a eventos.", "contactsEnableSuggestions": "Activar sugerencias de contactos", @@ -1155,7 +1156,7 @@ "windowMinimize": "Minimizar", "windowMaximize": "Maximizar", "windowRestore": "Restaurar", - "openSharedCalendar": "Abrir calendario compartido", + "openSharedCalendar": "Abrir calendario principal compartido", "calendarOwnerEmail": "Correo del propietario del calendario", "addCalendarShare": "Añadir acceso", "calendarShareFreeBusy": "Solo libre/ocupado", @@ -1359,5 +1360,49 @@ } }, "googleIdentityUnverified": "No se pudo verificar la identidad de esta conexión antigua de Google. Se conservan los calendarios, tareas y cambios pendientes. Añade una conexión de Google separada para sincronizar con seguridad. Los cambios pendientes seguirán en la cuenta original.", - "addSeparateGoogleAccount": "Añadir conexión de Google separada" + "addSeparateGoogleAccount": "Añadir conexión de Google separada", + "seriesPatternChangeConfirmation": "Este cambio en la repetición puede añadir o eliminar fechas.\n\nActual: {before}\nPropuesta: {after}\n\n¿Guardar esta repetición?", + "connectedAccounts": "Cuentas conectadas", + "contactsIndependentAccounts": "Cuentas de contactos independientes", + "contactsNextcloudReuse": "Las sugerencias reutilizan las credenciales de esta cuenta mediante la integración de contactos de solo lectura.", + "contactsRetrySync": "Reintentar sincronización de contactos", + "contactsPreferenceFailed": "No se pudo guardar la preferencia de la fuente de contactos.", + "contactsSuggestionsDisabled": "Sugerencias de contactos desactivadas", + "contactsSyncFailed": "No se pudieron sincronizar los contactos. Reinténtelo o vuelva a conectar si la autorización caducó.", + "registrationRecoveryDescription": "El registro guardado o su vínculo de credenciales no está disponible o fue rechazado. Sustituya explícitamente el registro de esta cuenta para recuperarla; se conservan los datos.", + "revalidateAccount": "Revalidar conexión", + "openSharedPrimaryDescription": "Abre el calendario principal del propietario indicado. Los demás calendarios compartidos se descubren mediante la sincronización habitual.", + "showInBusyMax": "Mostrar en BusyMax", + "showInGoogleCalendarList": "Mostrar en la lista de calendarios de Google", + "googleVisibilityHelp": "La selección de BusyMax es local. La visibilidad de Google cambia su lista en Google; los calendarios ocultos no se pueden mostrar en BusyMax.", + "primaryCalendarVisibilityExplanation": "El calendario principal de Google permanece en su lista.", + "accountsLoading": "Cargando cuentas…", + "accountsLoadFailed": "No se pudieron cargar las cuentas. Vuelva a abrir Cuentas.", + "collectionsLoading": "Cargando colecciones…", + "collectionsLoadFailed": "No se pudieron cargar las colecciones. Se conserva la caché; reintente la sincronización.", + "collectionsNotDiscovered": "El descubrimiento no ha terminado. Actualice o vuelva a conectar esta cuenta.", + "collectionsEmpty": "No se encontraron colecciones.", + "accountReadyToSync": "Listo para sincronizar", + "accountSyncProblem": "La sincronización requiere atención", + "accountServiceUnreachable": "No se pudo acceder al servicio", + "accountSyncDetails": "Detalles de sincronización", + "accountReconnectStatus": "Es necesario volver a conectar", + "accountRegistrationStatus": "El registro requiere atención", + "collectionsUnavailable": "Colecciones no disponibles", + "accountContactSuggestions": "Sugerir invitados de los contactos de esta cuenta", + "accountLoadingSignInSettings": "Cargando ajustes de inicio de sesión…", + "contactsNoAddressBooks": "No hay libretas de direcciones disponibles", + "contactsSourcesUnavailable": "No se pudieron cargar las libretas de direcciones", + "syncNoRecord": "No se registró ninguna sincronización correcta", + "cachedSyncError": "Falló la sincronización; los datos en caché pueden estar desactualizados.", + "calendarRemotePending": "Cambio de visibilidad en Google pendiente", + "calendarRemoteFailed": "Falló el cambio de visibilidad en Google. Se muestra el último estado confirmado de Google. BusyMax sigue aplicando el cambio pendiente. Reinténtalo o descártalo.", + "discardCalendarSettingsConfirmation": "¿Descartar los cambios pendientes de «{calendar}»?\n\nEstos ajustes volverán a sus valores anteriores:\n{settings}\n\nSe conserva tu selección de calendarios en BusyMax. No se enviarán cambios a Google.", + "sharedCalendarUnavailable": "Acceso no disponible; se muestra la caché. Reintente la sincronización o quite el calendario de su lista.", + "invalidCollectionColor": "Introduzca un color hexadecimal de seis u ocho dígitos que empiece por #.", + "invalidCollectionOrder": "Introduzca un número entero.", + "invalidCollectionTimezone": "Introduzca un documento de zona horaria válido que contenga VTIMEZONE y TZID.", + "invalidCollectionValue": "Corrija este valor antes de guardar.", + "accountAlreadyConnected": "Esta cuenta ya está conectada. Use su acción de reconexión.", + "nextcloudRemovalAuthorizationDescription": "BusyMax también intentará revocar la contraseña de aplicación de Nextcloud de esta cuenta. La eliminación local puede completarse aunque falle la revocación." } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 889ffb13..e441e613 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Kontaktikontosid ei õnnestunud laadida", "contactsTitle": "Kontaktid", "contactsLinkedDescription": "Kasuta sündmuse külaliste lisamisel selle konto vahemällu salvestatud kontakte.", "contactsEnableSuggestions": "Luba kontaktisoovitused", @@ -1270,7 +1271,7 @@ "windowMinimize": "Minimeeri", "windowMaximize": "Maksimeeri", "windowRestore": "Taasta", - "openSharedCalendar": "Ava jagatud kalender", + "openSharedCalendar": "Ava jagatud põhikalender", "calendarOwnerEmail": "Kalendri omaniku e-posti aadress", "addCalendarShare": "Lisa juurdepääs", "calendarShareFreeBusy": "Ainult vaba/hõivatud", @@ -1474,5 +1475,49 @@ } }, "googleIdentityUnverified": "Selle vana Google’i ühenduse identiteeti ei saanud kinnitada. Kalendrid, ülesanded ja ootel muudatused säilitatakse. Lisa turvaliseks sünkroonimiseks eraldi Google’i ühendus. Ootel muudatused jäävad algse konto juurde.", - "addSeparateGoogleAccount": "Lisa eraldi Google’i ühendus" + "addSeparateGoogleAccount": "Lisa eraldi Google’i ühendus", + "seriesPatternChangeConfirmation": "Kordusmustri muutmine võib kuupäevi lisada või eemaldada.\n\nPraegune: {before}\nPakutud: {after}\n\nKas salvestada see muster?", + "connectedAccounts": "Ühendatud kontod", + "contactsIndependentAccounts": "Sõltumatud kontaktikontod", + "contactsNextcloudReuse": "Kontaktisoovitused kasutavad selle konto olemasolevaid pääsuandmeid kirjutuskaitstud kontaktide integratsiooni kaudu.", + "contactsRetrySync": "Proovi kontaktide sünkroonimist uuesti", + "contactsPreferenceFailed": "Kontaktiallika eelistust ei saanud salvestada.", + "contactsSuggestionsDisabled": "Kontaktisoovitused on välja lülitatud", + "contactsSyncFailed": "Kontakte ei saanud sünkroonida. Proovi uuesti või ühenda konto uuesti, kui volitus on aegunud.", + "registrationRecoveryDescription": "Salvestatud registreering või selle pääsuandmete seos pole saadaval või on tagasi lükatud. Taastamiseks asenda selle konto registreering sõnaselgelt; andmed säilivad.", + "revalidateAccount": "Kontrolli ühendust uuesti", + "openSharedPrimaryDescription": "Avab määratud omaniku põhikalendri. Muud jagatud kalendrid leitakse konto tavapärase sünkroonimise käigus.", + "showInBusyMax": "Kuva BusyMaxis", + "showInGoogleCalendarList": "Kuva Google’i kalendriloendis", + "googleVisibilityHelp": "BusyMaxi valik on kohalik. Google’i nähtavus muudab sinu loendit Google’is; peidetud kalendreid ei saa BusyMaxis kuvada.", + "primaryCalendarVisibilityExplanation": "Google’i põhikalender jääb loendisse.", + "accountsLoading": "Kontode laadimine…", + "accountsLoadFailed": "Kontosid ei saanud laadida. Ava Kontod uuesti.", + "collectionsLoading": "Kogumite laadimine…", + "collectionsLoadFailed": "Kogumeid ei saanud laadida. Vahemälu säilib; proovi sünkroonimist uuesti.", + "collectionsNotDiscovered": "Tuvastamine pole lõppenud. Värskenda või ühenda konto uuesti.", + "collectionsEmpty": "Kogumeid ei leitud.", + "accountReadyToSync": "Sünkroonimiseks valmis", + "accountSyncProblem": "Sünkroonimine vajab tähelepanu", + "accountServiceUnreachable": "Teenusega ei saanud ühendust", + "accountSyncDetails": "Sünkroonimise üksikasjad", + "accountReconnectStatus": "Ühenda konto uuesti", + "accountRegistrationStatus": "Rakenduse registreering vajab tähelepanu", + "collectionsUnavailable": "Kogud pole saadaval", + "accountContactSuggestions": "Soovita külalisi selle konto kontaktidest", + "accountLoadingSignInSettings": "Sisselogimisseadete laadimine…", + "contactsNoAddressBooks": "Aadressiraamatud pole saadaval", + "contactsSourcesUnavailable": "Aadressiraamatuid ei saanud laadida", + "syncNoRecord": "Eduka sünkroonimise aega pole salvestatud", + "cachedSyncError": "Sünkroonimine nurjus; vahemälu andmed võivad olla aegunud.", + "calendarRemotePending": "Google’i nähtavuse muudatus on ootel", + "calendarRemoteFailed": "Google’i nähtavuse muutmine ebaõnnestus. Kuvatakse Google’i viimane kinnitatud olek. BusyMax järgib endiselt ootel muudatust. Proovi uuesti või loobu muudatusest.", + "discardCalendarSettingsConfirmation": "Kas hüljata kalendri „{calendar}“ ootel muudatused?\n\nNende seadete eelmised väärtused taastatakse:\n{settings}\n\nSinu BusyMaxi kalendrivalik säilib. Google’ile ei saadeta muudatusi.", + "sharedCalendarUnavailable": "Juurdepääs puudub; kuvatakse vahemälu andmed. Proovi sünkroonimist uuesti või eemalda kalender loendist.", + "invalidCollectionColor": "Sisesta kuue- või kaheksakohaline kuueteistkümnendvärv, alustades märgiga #.", + "invalidCollectionOrder": "Sisesta täisarv.", + "invalidCollectionTimezone": "Sisesta kehtiv kalendri ajavööndi dokument, mis sisaldab VTIMEZONE ja TZID väärtusi.", + "invalidCollectionValue": "Paranda see väärtus enne salvestamist.", + "accountAlreadyConnected": "See konto on juba ühendatud. Kasuta selle uuesti ühendamise toimingut.", + "nextcloudRemovalAuthorizationDescription": "BusyMax proovib tühistada ka selle konto Nextcloudi rakenduse parooli. Kohalik eemaldamine võib õnnestuda ka siis, kui tühistamine nurjub." } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index d2f71cb0..10635313 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "حساب‌های مخاطبین بارگیری نشدند", "contactsTitle": "مخاطبان", "contactsLinkedDescription": "هنگام افزودن مهمانان رویداد از مخاطبان ذخیره‌شدهٔ این حساب استفاده شود.", "contactsEnableSuggestions": "فعال‌کردن پیشنهادهای مخاطبان", @@ -1166,7 +1167,7 @@ "windowMinimize": "کوچک کردن", "windowMaximize": "بزرگ کردن", "windowRestore": "بازیابی", - "openSharedCalendar": "باز کردن تقویم اشتراکی", + "openSharedCalendar": "باز کردن تقویم اصلی اشتراکی", "calendarOwnerEmail": "ایمیل مالک تقویم", "addCalendarShare": "افزودن دسترسی", "calendarShareFreeBusy": "فقط آزاد/مشغول", @@ -1370,5 +1371,49 @@ } }, "googleIdentityUnverified": "هویت این اتصال قدیمی Google تأیید نشد. تقویم‌ها، کارها و تغییرات در انتظار حفظ شده‌اند. برای همگام‌سازی امن، یک اتصال Google جداگانه اضافه کنید. تغییرات در انتظار در حساب اصلی باقی می‌مانند.", - "addSeparateGoogleAccount": "افزودن اتصال Google جداگانه" + "addSeparateGoogleAccount": "افزودن اتصال Google جداگانه", + "seriesPatternChangeConfirmation": "این تغییر در الگوی تکرار ممکن است تاریخ‌هایی را اضافه یا حذف کند.\n\nفعلی: \u2068{before}\u2069\nپیشنهادی: \u2068{after}\u2069\n\nاین الگو ذخیره شود؟", + "connectedAccounts": "حساب‌های متصل", + "contactsIndependentAccounts": "حساب‌های مستقل مخاطبان", + "contactsNextcloudReuse": "پیشنهادهای مخاطب از اطلاعات ورود فعلی این حساب از طریق یکپارچه‌سازی فقط‌خواندنی مخاطبان استفاده می‌کنند.", + "contactsRetrySync": "تلاش دوباره برای همگام‌سازی مخاطبان", + "contactsPreferenceFailed": "ذخیره تنظیم منبع مخاطبان ممکن نشد.", + "contactsSuggestionsDisabled": "پیشنهادهای مخاطب غیرفعال است", + "contactsSyncFailed": "همگام‌سازی مخاطبان انجام نشد. دوباره تلاش کنید یا اگر مجوز منقضی شده حساب را دوباره متصل کنید.", + "registrationRecoveryDescription": "ثبت ذخیره‌شده یا پیوند اطلاعات ورود آن در دسترس نیست یا رد شده است. برای بازیابی، ثبت این حساب را به‌صراحت جایگزین کنید؛ داده‌های فعلی حفظ می‌شوند.", + "revalidateAccount": "بررسی دوباره اتصال", + "openSharedPrimaryDescription": "تقویم اصلی مالک مشخص‌شده را باز می‌کند. تقویم‌های اشتراکی دیگر از طریق همگام‌سازی عادی حساب پیدا می‌شوند.", + "showInBusyMax": "نمایش در BusyMax", + "showInGoogleCalendarList": "نمایش در فهرست تقویم Google", + "googleVisibilityHelp": "انتخاب BusyMax محلی است. نمایانی Google فهرست شما در Google را تغییر می‌دهد؛ تقویم‌های پنهان در BusyMax نمایش داده نمی‌شوند.", + "primaryCalendarVisibilityExplanation": "تقویم اصلی Google در فهرست باقی می‌ماند.", + "accountsLoading": "در حال بارگذاری حساب‌ها…", + "accountsLoadFailed": "حساب‌ها بارگذاری نشدند. بخش حساب‌ها را دوباره باز کنید.", + "collectionsLoading": "در حال بارگذاری مجموعه‌ها…", + "collectionsLoadFailed": "مجموعه‌ها بارگذاری نشدند. داده‌های ذخیره‌شده حفظ می‌شوند؛ همگام‌سازی را دوباره امتحان کنید.", + "collectionsNotDiscovered": "کشف کامل نشده است. این حساب را تازه‌سازی یا دوباره متصل کنید.", + "collectionsEmpty": "مجموعه‌ای پیدا نشد.", + "accountReadyToSync": "آمادهٔ همگام‌سازی", + "accountSyncProblem": "همگام‌سازی نیاز به رسیدگی دارد", + "accountServiceUnreachable": "دسترسی به سرویس ممکن نشد", + "accountSyncDetails": "جزئیات همگام‌سازی", + "accountReconnectStatus": "اتصال مجدد لازم است", + "accountRegistrationStatus": "ثبت برنامه نیاز به رسیدگی دارد", + "collectionsUnavailable": "مجموعه‌ها در دسترس نیستند", + "accountContactSuggestions": "پیشنهاد مهمان از مخاطبان این حساب", + "accountLoadingSignInSettings": "در حال بارگیری تنظیمات ورود…", + "contactsNoAddressBooks": "دفترچهٔ نشانی در دسترس نیست", + "contactsSourcesUnavailable": "بارگیری دفترچه‌های نشانی ممکن نشد", + "syncNoRecord": "زمان همگام‌سازی موفق ثبت نشده است", + "cachedSyncError": "همگام‌سازی ناموفق بود؛ داده‌های ذخیره‌شده ممکن است قدیمی باشند.", + "calendarRemotePending": "تغییر نمایانی Google در انتظار است", + "calendarRemoteFailed": "تغییر نمایش در Google ناموفق بود. آخرین وضعیت تأییدشدهٔ Google نمایش داده می‌شود. BusyMax همچنان از تغییر در انتظار پیروی می‌کند. دوباره تلاش کنید یا تغییر را کنار بگذارید.", + "discardCalendarSettingsConfirmation": "تغییرات در انتظار برای «⁨{calendar}⁩» کنار گذاشته شوند؟\n\nاین تنظیمات به مقادیر قبلی بازمی‌گردند:\n⁨{settings}⁩\n\nانتخاب تقویم شما در BusyMax حفظ می‌شود. هیچ تغییری به Google ارسال نخواهد شد.", + "sharedCalendarUnavailable": "دسترسی موجود نیست؛ داده‌های ذخیره‌شده نمایش داده می‌شوند. همگام‌سازی را دوباره امتحان کنید یا تقویم را از فهرست حذف کنید.", + "invalidCollectionColor": "یک رنگ شانزده‌شانزدهی شش یا هشت رقمی با # در ابتدا وارد کنید.", + "invalidCollectionOrder": "یک عدد صحیح وارد کنید.", + "invalidCollectionTimezone": "یک سند منطقه زمانی معتبر شامل VTIMEZONE و TZID وارد کنید.", + "invalidCollectionValue": "این مقدار را پیش از ذخیره اصلاح کنید.", + "accountAlreadyConnected": "این حساب از قبل متصل است. از گزینه اتصال دوباره آن استفاده کنید.", + "nextcloudRemovalAuthorizationDescription": "BusyMax تلاش می‌کند گذرواژه برنامه Nextcloud این حساب را نیز لغو کند. حذف محلی حتی در صورت ناموفق بودن لغو می‌تواند انجام شود." } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 16d7d619..13bb462c 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Yhteystietotilejä ei voitu ladata", "contactsTitle": "Yhteystiedot", "contactsLinkedDescription": "Käytä tämän tilin välimuistissa olevia yhteystietoja tapahtuman vieraita lisättäessä.", "contactsEnableSuggestions": "Ota yhteystietoehdotukset käyttöön", @@ -1092,7 +1093,7 @@ "windowMinimize": "Pienennä", "windowMaximize": "Suurenna", "windowRestore": "Palauta", - "openSharedCalendar": "Avaa jaettu kalenteri", + "openSharedCalendar": "Avaa jaettu ensisijainen kalenteri", "calendarOwnerEmail": "Kalenterin omistajan sähköpostiosoite", "addCalendarShare": "Lisää käyttöoikeus", "calendarShareFreeBusy": "Vain vapaa/varattu", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "Tämän vanhan Google-yhteyden henkilöllisyyttä ei voitu vahvistaa. Kalenterit, tehtävät ja odottavat muutokset säilytetään. Lisää erillinen Google-yhteys turvallista synkronointia varten. Odottavat muutokset jäävät alkuperäiseen tiliin.", - "addSeparateGoogleAccount": "Lisää erillinen Google-yhteys" + "addSeparateGoogleAccount": "Lisää erillinen Google-yhteys", + "seriesPatternChangeConfirmation": "Toistumissäännön muuttaminen voi lisätä tai poistaa päivämääriä.\n\nNykyinen: {before}\nEhdotettu: {after}\n\nTallennetaanko tämä sääntö?", + "connectedAccounts": "Yhdistetyt tilit", + "contactsIndependentAccounts": "Erilliset yhteystietotilit", + "contactsNextcloudReuse": "Yhteystietoehdotukset käyttävät tämän tilin nykyisiä tunnistetietoja vain luku -integraation kautta.", + "contactsRetrySync": "Yritä yhteystietojen synkronointia uudelleen", + "contactsPreferenceFailed": "Yhteystietolähteen asetusta ei voitu tallentaa.", + "contactsSuggestionsDisabled": "Yhteystietoehdotukset poistettu käytöstä", + "contactsSyncFailed": "Yhteystietoja ei voitu synkronoida. Yritä uudelleen tai yhdistä tili uudelleen, jos valtuutus on vanhentunut.", + "registrationRecoveryDescription": "Tallennettu rekisteröinti tai sen tunnistetietosidos puuttuu tai on hylätty. Korvaa tämän tilin rekisteröinti erikseen palautusta varten; tiedot säilytetään.", + "revalidateAccount": "Tarkista yhteys uudelleen", + "openSharedPrimaryDescription": "Avaa annetun omistajan ensisijaisen kalenterin. Muut jaetut kalenterit löytyvät tavallisen tilisynkronoinnin kautta.", + "showInBusyMax": "Näytä BusyMaxissa", + "showInGoogleCalendarList": "Näytä Googlen kalenteriluettelossa", + "googleVisibilityHelp": "BusyMaxin valinta on paikallinen. Google-näkyvyys muuttaa Googlen luetteloa; piilotettuja kalentereita ei voi näyttää BusyMaxissa.", + "primaryCalendarVisibilityExplanation": "Googlen ensisijainen kalenteri pysyy luettelossa.", + "accountsLoading": "Ladataan tilejä…", + "accountsLoadFailed": "Tilejä ei voitu ladata. Avaa Tilit uudelleen.", + "collectionsLoading": "Ladataan kokoelmia…", + "collectionsLoadFailed": "Kokoelmia ei voitu ladata. Välimuisti säilytetään; yritä synkronointia uudelleen.", + "collectionsNotDiscovered": "Haku ei ole vielä valmis. Päivitä tai yhdistä tämä tili uudelleen.", + "collectionsEmpty": "Kokoelmia ei löytynyt.", + "accountReadyToSync": "Valmis synkronointiin", + "accountSyncProblem": "Synkronointi vaatii huomiota", + "accountServiceUnreachable": "Palveluun ei saatu yhteyttä", + "accountSyncDetails": "Synkronoinnin tiedot", + "accountReconnectStatus": "Yhdistä tili uudelleen", + "accountRegistrationStatus": "Sovellusrekisteröinti vaatii huomiota", + "collectionsUnavailable": "Kokoelmat eivät ole käytettävissä", + "accountContactSuggestions": "Ehdota vieraita tämän tilin yhteystiedoista", + "accountLoadingSignInSettings": "Ladataan kirjautumisasetuksia…", + "contactsNoAddressBooks": "Osoitekirjoja ei ole käytettävissä", + "contactsSourcesUnavailable": "Osoitekirjoja ei voitu ladata", + "syncNoRecord": "Onnistuneen synkronoinnin aikaa ei ole tallennettu", + "cachedSyncError": "Synkronointi epäonnistui; välimuistin tiedot voivat olla vanhentuneita.", + "calendarRemotePending": "Googlen näkyvyysmuutos odottaa", + "calendarRemoteFailed": "Näkyvyyden muuttaminen Googlessa epäonnistui. Googlen viimeisin vahvistettu tila näytetään. BusyMax noudattaa yhä odottavaa muutosta. Yritä uudelleen tai hylkää muutos.", + "discardCalendarSettingsConfirmation": "Hylätäänkö kalenterin ”{calendar}” odottavat muutokset?\n\nNämä asetukset palautetaan aiempiin arvoihinsa:\n{settings}\n\nBusyMaxin kalenterivalintasi säilyy. Googlelle ei lähetetä muutoksia.", + "sharedCalendarUnavailable": "Ei käyttöoikeutta; näytetään välimuistin tiedot. Yritä synkronointia uudelleen tai poista kalenteri luettelosta.", + "invalidCollectionColor": "Anna kuusi- tai kahdeksannumeroinen heksaväri, joka alkaa #-merkillä.", + "invalidCollectionOrder": "Anna kokonaisluku.", + "invalidCollectionTimezone": "Anna kelvollinen kalenterin aikavyöhykeasiakirja, joka sisältää VTIMEZONE- ja TZID-tiedot.", + "invalidCollectionValue": "Korjaa tämä arvo ennen tallentamista.", + "accountAlreadyConnected": "Tämä tili on jo yhdistetty. Käytä sen uudelleenyhdistämistoimintoa.", + "nextcloudRemovalAuthorizationDescription": "BusyMax yrittää myös perua tämän tilin Nextcloud-sovellussalasanan. Paikallinen poisto voi onnistua, vaikka peruminen epäonnistuu." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 209afdd7..2f216fd3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Impossible de charger les comptes de contacts", "contactsTitle": "Carnet d’adresses", "contactsLinkedDescription": "Utiliser les contacts en cache de ce compte lors de l’ajout d’invités aux événements.", "contactsEnableSuggestions": "Activer les suggestions de contacts", @@ -1155,7 +1156,7 @@ "windowMinimize": "Réduire", "windowMaximize": "Agrandir", "windowRestore": "Restaurer", - "openSharedCalendar": "Ouvrir un calendrier partagé", + "openSharedCalendar": "Ouvrir un calendrier principal partagé", "calendarOwnerEmail": "Adresse e-mail du propriétaire du calendrier", "addCalendarShare": "Ajouter un accès", "calendarShareFreeBusy": "Disponibilité uniquement", @@ -1359,5 +1360,49 @@ } }, "googleIdentityUnverified": "L’identité de cette ancienne connexion Google n’a pas pu être vérifiée. Les calendriers, tâches et modifications en attente sont conservés. Ajoutez une connexion Google distincte pour synchroniser en toute sécurité. Les modifications en attente restent dans le compte d’origine.", - "addSeparateGoogleAccount": "Ajouter une connexion Google distincte" + "addSeparateGoogleAccount": "Ajouter une connexion Google distincte", + "seriesPatternChangeConfirmation": "Cette modification du schéma de répétition peut ajouter ou supprimer des dates.\n\nActuel : {before}\nProposé : {after}\n\nEnregistrer ce schéma ?", + "connectedAccounts": "Comptes connectés", + "contactsIndependentAccounts": "Comptes de contacts indépendants", + "contactsNextcloudReuse": "Les suggestions réutilisent les identifiants de ce compte via l’intégration de contacts en lecture seule.", + "contactsRetrySync": "Réessayer la synchronisation des contacts", + "contactsPreferenceFailed": "Impossible d’enregistrer la préférence de la source de contacts.", + "contactsSuggestionsDisabled": "Suggestions de contacts désactivées", + "contactsSyncFailed": "Les contacts n’ont pas pu être synchronisés. Réessayez ou reconnectez le compte si l’autorisation a expiré.", + "registrationRecoveryDescription": "L’inscription enregistrée ou son association aux identifiants est indisponible ou refusée. Remplacez explicitement l’inscription de ce compte pour le récupérer ; les données sont conservées.", + "revalidateAccount": "Revérifier la connexion", + "openSharedPrimaryDescription": "Ouvre le calendrier principal du propriétaire indiqué. Les autres calendriers partagés sont détectés lors de la synchronisation habituelle.", + "showInBusyMax": "Afficher dans BusyMax", + "showInGoogleCalendarList": "Afficher dans la liste de calendriers Google", + "googleVisibilityHelp": "La sélection BusyMax est locale. La visibilité Google modifie votre liste chez Google ; les calendriers masqués ne peuvent pas être affichés dans BusyMax.", + "primaryCalendarVisibilityExplanation": "Le calendrier principal Google reste dans sa liste.", + "accountsLoading": "Chargement des comptes…", + "accountsLoadFailed": "Impossible de charger les comptes. Rouvrez Comptes.", + "collectionsLoading": "Chargement des collections…", + "collectionsLoadFailed": "Impossible de charger les collections. Le cache est conservé ; réessayez la synchronisation.", + "collectionsNotDiscovered": "La découverte n’est pas terminée. Actualisez ou reconnectez ce compte.", + "collectionsEmpty": "Aucune collection trouvée.", + "accountReadyToSync": "Prêt à synchroniser", + "accountSyncProblem": "La synchronisation nécessite une intervention", + "accountServiceUnreachable": "Impossible de joindre le service", + "accountSyncDetails": "Détails de synchronisation", + "accountReconnectStatus": "Reconnexion nécessaire", + "accountRegistrationStatus": "L’enregistrement nécessite une intervention", + "collectionsUnavailable": "Collections indisponibles", + "accountContactSuggestions": "Suggérer des invités à partir des contacts de ce compte", + "accountLoadingSignInSettings": "Chargement des paramètres de connexion…", + "contactsNoAddressBooks": "Aucun carnet d’adresses disponible", + "contactsSourcesUnavailable": "Impossible de charger les carnets d’adresses", + "syncNoRecord": "Aucune date de synchronisation réussie enregistrée", + "cachedSyncError": "Échec de synchronisation ; les données en cache peuvent être anciennes.", + "calendarRemotePending": "Modification de visibilité Google en attente", + "calendarRemoteFailed": "La modification de visibilité dans Google a échoué. Le dernier état confirmé de Google est affiché. BusyMax suit toujours la modification en attente. Réessayez ou abandonnez cette modification.", + "discardCalendarSettingsConfirmation": "Abandonner les modifications en attente pour « {calendar} » ?\n\nCes paramètres retrouveront leurs valeurs précédentes :\n{settings}\n\nVotre sélection de calendriers dans BusyMax est conservée. Aucune modification ne sera envoyée à Google.", + "sharedCalendarUnavailable": "Accès indisponible ; affichage du cache. Réessayez la synchronisation ou retirez le calendrier de votre liste.", + "invalidCollectionColor": "Saisissez une couleur hexadécimale de six ou huit chiffres commençant par #.", + "invalidCollectionOrder": "Saisissez un nombre entier.", + "invalidCollectionTimezone": "Saisissez un document de fuseau horaire valide contenant VTIMEZONE et TZID.", + "invalidCollectionValue": "Corrigez cette valeur avant d’enregistrer.", + "accountAlreadyConnected": "Ce compte est déjà connecté. Utilisez son action de reconnexion.", + "nextcloudRemovalAuthorizationDescription": "BusyMax tentera aussi de révoquer le mot de passe d’application Nextcloud de ce compte. La suppression locale peut réussir même si cette révocation échoue." } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index c6d3f0b4..f9d80641 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "संपर्क खाते लोड नहीं किए जा सके", "contactsTitle": "संपर्क", "contactsLinkedDescription": "इवेंट में अतिथि जोड़ते समय इस खाते के कैश किए गए संपर्कों का उपयोग करें।", "contactsEnableSuggestions": "संपर्क सुझाव चालू करें", @@ -1092,7 +1093,7 @@ "windowMinimize": "छोटा करें", "windowMaximize": "अधिकतम करें", "windowRestore": "पुनर्स्थापित करें", - "openSharedCalendar": "साझा कैलेंडर खोलें", + "openSharedCalendar": "साझा मुख्य कैलेंडर खोलें", "calendarOwnerEmail": "कैलेंडर स्वामी का ईमेल", "addCalendarShare": "पहुँच जोड़ें", "calendarShareFreeBusy": "केवल खाली/व्यस्त", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "इस पुराने Google कनेक्शन की पहचान सत्यापित नहीं हो सकी। कैलेंडर, कार्य और लंबित बदलाव सुरक्षित हैं। सुरक्षित सिंक के लिए अलग Google कनेक्शन जोड़ें। लंबित बदलाव मूल खाते में रहेंगे।", - "addSeparateGoogleAccount": "अलग Google कनेक्शन जोड़ें" + "addSeparateGoogleAccount": "अलग Google कनेक्शन जोड़ें", + "seriesPatternChangeConfirmation": "दोहराव का यह बदलाव तारीखें जोड़ या हटा सकता है।\n\nवर्तमान: {before}\nप्रस्तावित: {after}\n\nयह पैटर्न सहेजें?", + "connectedAccounts": "जुड़े हुए खाते", + "contactsIndependentAccounts": "स्वतंत्र संपर्क खाते", + "contactsNextcloudReuse": "संपर्क सुझाव केवल पढ़ने वाले संपर्क एकीकरण के माध्यम से इस खाते के मौजूदा क्रेडेंशियल का दोबारा उपयोग करते हैं।", + "contactsRetrySync": "संपर्क सिंक फिर आज़माएँ", + "contactsPreferenceFailed": "संपर्क स्रोत की प्राथमिकता सहेजी नहीं जा सकी।", + "contactsSuggestionsDisabled": "संपर्क सुझाव बंद हैं", + "contactsSyncFailed": "संपर्क सिंक नहीं हो सके। फिर आज़माएँ, या अनुमति समाप्त होने पर खाता फिर जोड़ें।", + "registrationRecoveryDescription": "सहेजा गया पंजीकरण या उसका क्रेडेंशियल संबंध उपलब्ध नहीं है या अस्वीकार कर दिया गया है। बहाली के लिए इस खाते का पंजीकरण स्पष्ट रूप से बदलें; मौजूदा डेटा सुरक्षित रहेगा।", + "revalidateAccount": "कनेक्शन फिर सत्यापित करें", + "openSharedPrimaryDescription": "निर्दिष्ट मालिक का मुख्य कैलेंडर खोलता है। अन्य साझा कैलेंडर सामान्य खाता सिंक में खोजे जाते हैं।", + "showInBusyMax": "BusyMax में दिखाएँ", + "showInGoogleCalendarList": "Google की कैलेंडर सूची में दिखाएँ", + "googleVisibilityHelp": "BusyMax का चयन स्थानीय है। Google दृश्यता Google पर आपकी सूची बदलती है; छिपे कैलेंडर BusyMax में नहीं दिखाए जा सकते।", + "primaryCalendarVisibilityExplanation": "Google का मुख्य कैलेंडर उसकी सूची में रहता है।", + "accountsLoading": "खाते लोड हो रहे हैं…", + "accountsLoadFailed": "खाते लोड नहीं हो सके। खाते फिर खोलें।", + "collectionsLoading": "संग्रह लोड हो रहे हैं…", + "collectionsLoadFailed": "संग्रह लोड नहीं हो सके। कैश डेटा सुरक्षित है; सिंक फिर आज़माएँ।", + "collectionsNotDiscovered": "खोज पूरी नहीं हुई है। इस खाते को रीफ़्रेश करें या फिर जोड़ें।", + "collectionsEmpty": "कोई संग्रह नहीं मिला।", + "accountReadyToSync": "सिंक करने के लिए तैयार", + "accountSyncProblem": "सिंक पर ध्यान देना आवश्यक है", + "accountServiceUnreachable": "सेवा से संपर्क नहीं हो सका", + "accountSyncDetails": "सिंक का विवरण", + "accountReconnectStatus": "दोबारा कनेक्ट करना आवश्यक है", + "accountRegistrationStatus": "ऐप पंजीकरण पर ध्यान देना आवश्यक है", + "collectionsUnavailable": "संग्रह उपलब्ध नहीं हैं", + "accountContactSuggestions": "इस खाते के संपर्कों से मेहमान सुझाएँ", + "accountLoadingSignInSettings": "साइन-इन सेटिंग लोड हो रही हैं…", + "contactsNoAddressBooks": "कोई पता पुस्तिका उपलब्ध नहीं है", + "contactsSourcesUnavailable": "पता पुस्तिकाएँ लोड नहीं हो सकीं", + "syncNoRecord": "सफल सिंक का समय दर्ज नहीं है", + "cachedSyncError": "सिंक विफल हुआ; कैश डेटा पुराना हो सकता है।", + "calendarRemotePending": "Google दृश्यता बदलाव लंबित है", + "calendarRemoteFailed": "Google की दृश्यता बदलना विफल रहा। Google की पिछली पुष्ट स्थिति दिखाई गई है। BusyMax अभी भी लंबित बदलाव का पालन करता है। फिर से कोशिश करें या बदलाव छोड़ दें।", + "discardCalendarSettingsConfirmation": "“{calendar}” के लंबित बदलाव छोड़ें?\n\nये सेटिंग अपने पिछले मानों पर लौट जाएँगी:\n{settings}\n\nBusyMax में आपका कैलेंडर चयन बना रहेगा। Google को कोई बदलाव नहीं भेजा जाएगा।", + "sharedCalendarUnavailable": "पहुँच उपलब्ध नहीं; कैश डेटा दिखाया जा रहा है। सिंक फिर आज़माएँ या कैलेंडर को अपनी सूची से हटाएँ।", + "invalidCollectionColor": "# से शुरू होने वाला छह या आठ अंकों का हेक्स रंग दर्ज करें।", + "invalidCollectionOrder": "एक पूर्णांक दर्ज करें।", + "invalidCollectionTimezone": "VTIMEZONE और TZID वाला मान्य कैलेंडर समय क्षेत्र दस्तावेज़ दर्ज करें।", + "invalidCollectionValue": "सहेजने से पहले यह मान सुधारें।", + "accountAlreadyConnected": "यह खाता पहले से जुड़ा है। इसके फिर से जोड़ने वाले विकल्प का उपयोग करें।", + "nextcloudRemovalAuthorizationDescription": "BusyMax इस खाते का Nextcloud ऐप पासवर्ड निरस्त करने का भी प्रयास करेगा। निरस्तीकरण विफल होने पर भी स्थानीय हटाना सफल हो सकता है।" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 21bb79c1..0372f7ae 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Impossibile caricare gli account dei contatti", "contactsTitle": "Contatti", "contactsLinkedDescription": "Usa i contatti memorizzati di questo account quando aggiungi invitati agli eventi.", "contactsEnableSuggestions": "Attiva i suggerimenti dei contatti", @@ -1092,7 +1093,7 @@ "windowMinimize": "Riduci", "windowMaximize": "Ingrandisci", "windowRestore": "Ripristina", - "openSharedCalendar": "Apri calendario condiviso", + "openSharedCalendar": "Apri calendario principale condiviso", "calendarOwnerEmail": "Email del proprietario del calendario", "addCalendarShare": "Aggiungi accesso", "calendarShareFreeBusy": "Solo libero/occupato", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "Non è stato possibile verificare l’identità di questa vecchia connessione Google. Calendari, attività e modifiche in sospeso sono conservati. Aggiungi una connessione Google separata per sincronizzare in sicurezza. Le modifiche in sospeso rimarranno nell’account originale.", - "addSeparateGoogleAccount": "Aggiungi connessione Google separata" + "addSeparateGoogleAccount": "Aggiungi connessione Google separata", + "seriesPatternChangeConfirmation": "Questa modifica della ricorrenza può aggiungere o rimuovere date.\n\nAttuale: {before}\nProposta: {after}\n\nSalvare questa ricorrenza?", + "connectedAccounts": "Account collegati", + "contactsIndependentAccounts": "Account di contatti indipendenti", + "contactsNextcloudReuse": "I suggerimenti riutilizzano le credenziali di questo account tramite l’integrazione dei contatti in sola lettura.", + "contactsRetrySync": "Riprova sincronizzazione contatti", + "contactsPreferenceFailed": "Impossibile salvare la preferenza della fonte dei contatti.", + "contactsSuggestionsDisabled": "Suggerimenti dei contatti disattivati", + "contactsSyncFailed": "Impossibile sincronizzare i contatti. Riprova oppure ricollega l’account se l’autorizzazione è scaduta.", + "registrationRecoveryDescription": "La registrazione salvata o il collegamento alle credenziali non è disponibile o è stato rifiutato. Sostituisci esplicitamente la registrazione di questo account per recuperarlo; i dati vengono conservati.", + "revalidateAccount": "Verifica di nuovo la connessione", + "openSharedPrimaryDescription": "Apre il calendario principale del proprietario specificato. Gli altri calendari condivisi vengono rilevati tramite la normale sincronizzazione.", + "showInBusyMax": "Mostra in BusyMax", + "showInGoogleCalendarList": "Mostra nell’elenco dei calendari Google", + "googleVisibilityHelp": "La selezione di BusyMax è locale. La visibilità Google modifica il tuo elenco su Google; i calendari nascosti non possono essere visualizzati in BusyMax.", + "primaryCalendarVisibilityExplanation": "Il calendario principale Google rimane nel suo elenco.", + "accountsLoading": "Caricamento account…", + "accountsLoadFailed": "Impossibile caricare gli account. Riapri Account.", + "collectionsLoading": "Caricamento raccolte…", + "collectionsLoadFailed": "Impossibile caricare le raccolte. I dati in cache sono conservati; riprova la sincronizzazione.", + "collectionsNotDiscovered": "Il rilevamento non è completo. Aggiorna o ricollega questo account.", + "collectionsEmpty": "Nessuna raccolta trovata.", + "accountReadyToSync": "Pronto per la sincronizzazione", + "accountSyncProblem": "La sincronizzazione richiede attenzione", + "accountServiceUnreachable": "Impossibile raggiungere il servizio", + "accountSyncDetails": "Dettagli di sincronizzazione", + "accountReconnectStatus": "È necessario riconnettere l’account", + "accountRegistrationStatus": "La registrazione richiede attenzione", + "collectionsUnavailable": "Raccolte non disponibili", + "accountContactSuggestions": "Suggerisci invitati dai contatti di questo account", + "accountLoadingSignInSettings": "Caricamento delle impostazioni di accesso…", + "contactsNoAddressBooks": "Nessuna rubrica disponibile", + "contactsSourcesUnavailable": "Impossibile caricare le rubriche", + "syncNoRecord": "Nessuna data di sincronizzazione riuscita registrata", + "cachedSyncError": "Sincronizzazione non riuscita; i dati in cache potrebbero essere obsoleti.", + "calendarRemotePending": "Modifica visibilità Google in attesa", + "calendarRemoteFailed": "La modifica della visibilità su Google non è riuscita. Viene mostrato l’ultimo stato confermato da Google. BusyMax continua a seguire la modifica in sospeso. Riprova o scarta la modifica.", + "discardCalendarSettingsConfirmation": "Scartare le modifiche in attesa per «{calendar}»?\n\nQueste impostazioni torneranno ai valori precedenti:\n{settings}\n\nLa selezione dei calendari in BusyMax viene mantenuta. Non verranno inviate modifiche a Google.", + "sharedCalendarUnavailable": "Accesso non disponibile; vengono mostrati i dati in cache. Riprova o rimuovi il calendario dal tuo elenco.", + "invalidCollectionColor": "Inserisci un colore esadecimale di sei o otto cifre che inizi con #.", + "invalidCollectionOrder": "Inserisci un numero intero.", + "invalidCollectionTimezone": "Inserisci un documento di fuso orario valido contenente VTIMEZONE e TZID.", + "invalidCollectionValue": "Correggi questo valore prima di salvare.", + "accountAlreadyConnected": "Questo account è già collegato. Usa la sua azione di riconnessione.", + "nextcloudRemovalAuthorizationDescription": "BusyMax tenterà anche di revocare la password per app Nextcloud di questo account. La rimozione locale può riuscire anche se la revoca non riesce." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 5d53ff2b..665af566 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "連絡先アカウントを読み込めませんでした", "contactsTitle": "連絡先", "contactsLinkedDescription": "予定のゲストを追加するときに、このアカウントのキャッシュ済み連絡先を使用します。", "contactsEnableSuggestions": "連絡先の候補を有効にする", @@ -1092,7 +1093,7 @@ "windowMinimize": "最小化", "windowMaximize": "最大化", "windowRestore": "元に戻す", - "openSharedCalendar": "共有カレンダーを開く", + "openSharedCalendar": "共有メインカレンダーを開く", "calendarOwnerEmail": "カレンダー所有者のメールアドレス", "addCalendarShare": "アクセスを追加", "calendarShareFreeBusy": "空き時間情報のみ", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "この古い Google 接続のアカウントを確認できませんでした。カレンダー、タスク、保留中の変更は保持されています。安全に同期するには、別の Google 接続を追加してください。保留中の変更は元のアカウントに残ります。", - "addSeparateGoogleAccount": "別の Google 接続を追加" + "addSeparateGoogleAccount": "別の Google 接続を追加", + "seriesPatternChangeConfirmation": "繰り返しパターンの変更により、日付が追加または削除される場合があります。\n\n現在:{before}\n変更案:{after}\n\nこのパターンを保存しますか?", + "connectedAccounts": "接続済みアカウント", + "contactsIndependentAccounts": "独立した連絡先アカウント", + "contactsNextcloudReuse": "連絡先候補は、読み取り専用の連絡先連携を通じて、このアカウントの既存の認証情報を再利用します。", + "contactsRetrySync": "連絡先の同期を再試行", + "contactsPreferenceFailed": "連絡先ソースの設定を保存できませんでした。", + "contactsSuggestionsDisabled": "連絡先候補は無効です", + "contactsSyncFailed": "連絡先を同期できませんでした。再試行するか、認可が期限切れの場合はアカウントを再接続してください。", + "registrationRecoveryDescription": "保存済みのアプリ登録または認証情報との関連付けが利用できないか、拒否されています。復旧するには、このアカウントの登録を明示的に置き換えてください。既存のデータは保持されます。", + "revalidateAccount": "接続を再検証", + "openSharedPrimaryDescription": "指定した所有者のメインカレンダーを開きます。他の共有カレンダーは通常のアカウント同期で検出されます。", + "showInBusyMax": "BusyMax に表示", + "showInGoogleCalendarList": "Google のカレンダーリストに表示", + "googleVisibilityHelp": "BusyMax の選択はローカル設定です。Google の表示設定は Google 側のリストを変更します。非表示のカレンダーは BusyMax に表示できません。", + "primaryCalendarVisibilityExplanation": "Google のメインカレンダーはリストに残ります。", + "accountsLoading": "アカウントを読み込み中…", + "accountsLoadFailed": "アカウントを読み込めませんでした。「アカウント」を開き直してください。", + "collectionsLoading": "コレクションを読み込み中…", + "collectionsLoadFailed": "コレクションを読み込めませんでした。キャッシュは保持されています。同期を再試行してください。", + "collectionsNotDiscovered": "検出が完了していません。このアカウントを更新または再接続してください。", + "collectionsEmpty": "コレクションが見つかりません。", + "accountReadyToSync": "同期の準備完了", + "accountSyncProblem": "同期への対応が必要", + "accountServiceUnreachable": "サービスに接続できませんでした", + "accountSyncDetails": "同期の詳細", + "accountReconnectStatus": "再接続が必要", + "accountRegistrationStatus": "アプリ登録への対応が必要", + "collectionsUnavailable": "コレクションを利用できません", + "accountContactSuggestions": "このアカウントの連絡先からゲストを候補に表示", + "accountLoadingSignInSettings": "サインイン設定を読み込み中…", + "contactsNoAddressBooks": "利用できるアドレス帳がありません", + "contactsSourcesUnavailable": "アドレス帳を読み込めませんでした", + "syncNoRecord": "成功した同期時刻の記録がありません", + "cachedSyncError": "同期に失敗しました。キャッシュのデータが古い可能性があります。", + "calendarRemotePending": "Google の表示設定の変更を待機中", + "calendarRemoteFailed": "Google での表示設定の変更に失敗しました。Google で最後に確認された状態が表示されています。BusyMax は引き続き保留中の変更に従います。再試行するか、変更を破棄してください。", + "discardCalendarSettingsConfirmation": "「{calendar}」の保留中の変更を破棄しますか?\n\n次の設定を以前の値に戻します:\n{settings}\n\nBusyMaxでのカレンダーの選択は保持されます。Googleに変更は送信されません。", + "sharedCalendarUnavailable": "アクセスできません。キャッシュを表示しています。同期を再試行するか、リストから削除してください。", + "invalidCollectionColor": "# で始まる6桁または8桁の16進数の色を入力してください。", + "invalidCollectionOrder": "整数を入力してください。", + "invalidCollectionTimezone": "VTIMEZONE と TZID を含む有効なカレンダーのタイムゾーン文書を入力してください。", + "invalidCollectionValue": "保存する前にこの値を修正してください。", + "accountAlreadyConnected": "このアカウントは既に接続されています。このアカウントの再接続操作を使用してください。", + "nextcloudRemovalAuthorizationDescription": "BusyMax はこのアカウントの Nextcloud アプリパスワードの失効も試みます。失効に失敗しても、ローカルでの削除は完了する場合があります。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index d53385ee..7efe6e04 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "연락처 계정을 불러올 수 없습니다", "contactsTitle": "연락처", "contactsLinkedDescription": "이벤트 참석자를 추가할 때 이 계정에 캐시된 연락처를 사용합니다.", "contactsEnableSuggestions": "연락처 추천 사용", @@ -1092,7 +1093,7 @@ "windowMinimize": "최소화", "windowMaximize": "최대화", "windowRestore": "복원", - "openSharedCalendar": "공유 캘린더 열기", + "openSharedCalendar": "공유 기본 캘린더 열기", "calendarOwnerEmail": "캘린더 소유자 이메일", "addCalendarShare": "액세스 추가", "calendarShareFreeBusy": "한가함/바쁨만 보기", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "이 오래된 Google 연결의 계정 신원을 확인할 수 없습니다. 캘린더, 할 일 및 대기 중인 변경 사항은 유지됩니다. 안전한 동기화를 위해 별도의 Google 연결을 추가하세요. 대기 중인 변경 사항은 원래 계정에 남습니다.", - "addSeparateGoogleAccount": "별도의 Google 연결 추가" + "addSeparateGoogleAccount": "별도의 Google 연결 추가", + "seriesPatternChangeConfirmation": "반복 패턴을 변경하면 날짜가 추가되거나 삭제될 수 있습니다.\n\n현재: {before}\n제안: {after}\n\n이 패턴을 저장할까요?", + "connectedAccounts": "연결된 계정", + "contactsIndependentAccounts": "독립 연락처 계정", + "contactsNextcloudReuse": "연락처 제안은 읽기 전용 연락처 통합을 통해 이 계정의 기존 자격 증명을 재사용합니다.", + "contactsRetrySync": "연락처 동기화 다시 시도", + "contactsPreferenceFailed": "연락처 소스 설정을 저장하지 못했습니다.", + "contactsSuggestionsDisabled": "연락처 제안 꺼짐", + "contactsSyncFailed": "연락처를 동기화하지 못했습니다. 다시 시도하거나 권한이 만료된 경우 계정을 다시 연결하세요.", + "registrationRecoveryDescription": "저장된 앱 등록 또는 자격 증명 연결을 사용할 수 없거나 거부되었습니다. 복구하려면 이 계정의 등록을 명시적으로 교체하세요. 기존 데이터는 유지됩니다.", + "revalidateAccount": "연결 다시 검증", + "openSharedPrimaryDescription": "지정한 소유자의 기본 캘린더를 엽니다. 다른 공유 캘린더는 일반 계정 동기화에서 검색됩니다.", + "showInBusyMax": "BusyMax에 표시", + "showInGoogleCalendarList": "Google 캘린더 목록에 표시", + "googleVisibilityHelp": "BusyMax 선택은 로컬 설정입니다. Google 표시 설정은 Google의 목록을 변경하며 숨겨진 캘린더는 BusyMax에 표시할 수 없습니다.", + "primaryCalendarVisibilityExplanation": "Google 기본 캘린더는 목록에 유지됩니다.", + "accountsLoading": "계정 불러오는 중…", + "accountsLoadFailed": "계정을 불러오지 못했습니다. 계정을 다시 여세요.", + "collectionsLoading": "컬렉션 불러오는 중…", + "collectionsLoadFailed": "컬렉션을 불러오지 못했습니다. 캐시는 유지됩니다. 동기화를 다시 시도하세요.", + "collectionsNotDiscovered": "검색이 완료되지 않았습니다. 이 계정을 새로 고치거나 다시 연결하세요.", + "collectionsEmpty": "컬렉션을 찾지 못했습니다.", + "accountReadyToSync": "동기화 준비됨", + "accountSyncProblem": "동기화에 조치가 필요합니다", + "accountServiceUnreachable": "서비스에 연결할 수 없습니다", + "accountSyncDetails": "동기화 세부 정보", + "accountReconnectStatus": "다시 연결해야 합니다", + "accountRegistrationStatus": "앱 등록에 조치가 필요합니다", + "collectionsUnavailable": "컬렉션을 사용할 수 없습니다", + "accountContactSuggestions": "이 계정의 연락처에서 참석자 제안", + "accountLoadingSignInSettings": "로그인 설정을 불러오는 중…", + "contactsNoAddressBooks": "사용 가능한 주소록이 없습니다", + "contactsSourcesUnavailable": "주소록을 불러올 수 없습니다", + "syncNoRecord": "성공한 동기화 시간이 기록되지 않음", + "cachedSyncError": "동기화 실패. 캐시 데이터가 오래되었을 수 있습니다.", + "calendarRemotePending": "Google 표시 변경 대기 중", + "calendarRemoteFailed": "Google 표시 설정 변경에 실패했습니다. 마지막으로 확인된 Google 상태가 표시됩니다. BusyMax는 계속 대기 중인 변경을 따릅니다. 다시 시도하거나 변경을 취소하세요.", + "discardCalendarSettingsConfirmation": "“{calendar}”의 대기 중인 변경사항을 취소할까요?\n\n다음 설정이 이전 값으로 복원됩니다:\n{settings}\n\nBusyMax의 캘린더 선택은 유지됩니다. Google에 변경사항이 전송되지 않습니다.", + "sharedCalendarUnavailable": "접근할 수 없어 캐시를 표시합니다. 동기화를 다시 시도하거나 캘린더를 목록에서 제거하세요.", + "invalidCollectionColor": "#으로 시작하는 6자리 또는 8자리 16진수 색상을 입력하세요.", + "invalidCollectionOrder": "정수를 입력하세요.", + "invalidCollectionTimezone": "VTIMEZONE 및 TZID를 포함하는 올바른 캘린더 시간대 문서를 입력하세요.", + "invalidCollectionValue": "저장하기 전에 이 값을 수정하세요.", + "accountAlreadyConnected": "이 계정은 이미 연결되어 있습니다. 해당 계정의 다시 연결 작업을 사용하세요.", + "nextcloudRemovalAuthorizationDescription": "BusyMax는 이 계정의 Nextcloud 앱 암호 해지도 시도합니다. 해지에 실패해도 로컬 제거는 완료될 수 있습니다." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f6a4543e..990d613d 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Nie udało się wczytać kont kontaktów", "contactsTitle": "Kontakty", "contactsLinkedDescription": "Używaj kontaktów z pamięci podręcznej tego konta podczas dodawania gości wydarzenia.", "contactsEnableSuggestions": "Włącz sugestie kontaktów", @@ -1773,7 +1774,7 @@ "windowMinimize": "Minimalizuj", "windowMaximize": "Maksymalizuj", "windowRestore": "Przywróć", - "openSharedCalendar": "Otwórz udostępniony kalendarz", + "openSharedCalendar": "Otwórz udostępniony kalendarz główny", "calendarOwnerEmail": "Adres e-mail właściciela kalendarza", "addCalendarShare": "Dodaj dostęp", "calendarShareFreeBusy": "Tylko wolny/zajęty", @@ -1977,5 +1978,49 @@ } }, "googleIdentityUnverified": "Nie można zweryfikować tożsamości tego starszego połączenia Google. Kalendarze, zadania i oczekujące zmiany są zachowane. Dodaj osobne połączenie Google, aby bezpiecznie synchronizować. Oczekujące zmiany pozostaną na pierwotnym koncie.", - "addSeparateGoogleAccount": "Dodaj osobne połączenie Google" + "addSeparateGoogleAccount": "Dodaj osobne połączenie Google", + "seriesPatternChangeConfirmation": "Ta zmiana reguły powtarzania może dodać lub usunąć daty.\n\nObecnie: {before}\nPropozycja: {after}\n\nZapisać tę regułę?", + "connectedAccounts": "Połączone konta", + "contactsIndependentAccounts": "Niezależne konta kontaktów", + "contactsNextcloudReuse": "Sugestie używają istniejących danych logowania tego konta przez integrację kontaktów tylko do odczytu.", + "contactsRetrySync": "Ponów synchronizację kontaktów", + "contactsPreferenceFailed": "Nie udało się zapisać ustawienia źródła kontaktów.", + "contactsSuggestionsDisabled": "Sugestie kontaktów wyłączone", + "contactsSyncFailed": "Nie udało się zsynchronizować kontaktów. Spróbuj ponownie lub połącz konto ponownie, jeśli autoryzacja wygasła.", + "registrationRecoveryDescription": "Zapisana rejestracja lub powiązanie danych logowania jest niedostępne albo odrzucone. Jawnie zastąp rejestrację tego konta, aby je odzyskać; dane zostaną zachowane.", + "revalidateAccount": "Sprawdź połączenie ponownie", + "openSharedPrimaryDescription": "Otwiera główny kalendarz wskazanego właściciela. Inne udostępnione kalendarze są wykrywane podczas zwykłej synchronizacji konta.", + "showInBusyMax": "Pokaż w BusyMax", + "showInGoogleCalendarList": "Pokaż na liście kalendarzy Google", + "googleVisibilityHelp": "Wybór BusyMax jest lokalny. Widoczność Google zmienia listę w Google; ukrytych kalendarzy nie można wyświetlić w BusyMax.", + "primaryCalendarVisibilityExplanation": "Główny kalendarz Google pozostaje na liście.", + "accountsLoading": "Ładowanie kont…", + "accountsLoadFailed": "Nie udało się załadować kont. Otwórz Konta ponownie.", + "collectionsLoading": "Ładowanie kolekcji…", + "collectionsLoadFailed": "Nie udało się załadować kolekcji. Pamięć podręczna jest zachowana; ponów synchronizację.", + "collectionsNotDiscovered": "Wykrywanie nie zostało zakończone. Odśwież lub połącz konto ponownie.", + "collectionsEmpty": "Nie znaleziono kolekcji.", + "accountReadyToSync": "Gotowe do synchronizacji", + "accountSyncProblem": "Synchronizacja wymaga uwagi", + "accountServiceUnreachable": "Nie można połączyć się z usługą", + "accountSyncDetails": "Szczegóły synchronizacji", + "accountReconnectStatus": "Wymagane ponowne połączenie", + "accountRegistrationStatus": "Rejestracja aplikacji wymaga uwagi", + "collectionsUnavailable": "Kolekcje niedostępne", + "accountContactSuggestions": "Sugeruj gości z kontaktów tego konta", + "accountLoadingSignInSettings": "Wczytywanie ustawień logowania…", + "contactsNoAddressBooks": "Brak dostępnych książek adresowych", + "contactsSourcesUnavailable": "Nie można wczytać książek adresowych", + "syncNoRecord": "Brak zapisanego czasu udanej synchronizacji", + "cachedSyncError": "Synchronizacja nie powiodła się; dane w pamięci podręcznej mogą być nieaktualne.", + "calendarRemotePending": "Oczekiwanie na zmianę widoczności Google", + "calendarRemoteFailed": "Zmiana widoczności w Google nie powiodła się. Wyświetlany jest ostatni potwierdzony stan Google. BusyMax nadal stosuje oczekującą zmianę. Ponów próbę lub odrzuć zmianę.", + "discardCalendarSettingsConfirmation": "Odrzucić oczekujące zmiany kalendarza „{calendar}”?\n\nTe ustawienia wrócą do poprzednich wartości:\n{settings}\n\nWybór kalendarzy w BusyMax zostanie zachowany. Żadne zmiany nie zostaną wysłane do Google.", + "sharedCalendarUnavailable": "Dostęp niedostępny; wyświetlane są dane z pamięci podręcznej. Ponów synchronizację lub usuń kalendarz z listy.", + "invalidCollectionColor": "Wpisz sześcio- lub ośmiocyfrowy kolor szesnastkowy zaczynający się od #.", + "invalidCollectionOrder": "Wpisz liczbę całkowitą.", + "invalidCollectionTimezone": "Wpisz prawidłowy dokument strefy czasowej zawierający VTIMEZONE i TZID.", + "invalidCollectionValue": "Popraw tę wartość przed zapisaniem.", + "accountAlreadyConnected": "To konto jest już połączone. Użyj jego opcji ponownego połączenia.", + "nextcloudRemovalAuthorizationDescription": "BusyMax spróbuje też unieważnić hasło aplikacji Nextcloud tego konta. Usunięcie lokalne może się udać nawet wtedy, gdy unieważnienie się nie powiedzie." } diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index cfb5f04c..ecd11be3 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Não foi possível carregar as contas de contactos", "contactsTitle": "Contactos", "contactsLinkedDescription": "Utilize os contactos em cache desta conta ao adicionar convidados a eventos.", "contactsEnableSuggestions": "Ativar sugestões de contactos", @@ -1092,7 +1093,7 @@ "windowMinimize": "Minimizar", "windowMaximize": "Maximizar", "windowRestore": "Restaurar", - "openSharedCalendar": "Abrir calendário partilhado", + "openSharedCalendar": "Abrir calendário principal partilhado", "calendarOwnerEmail": "E-mail do proprietário do calendário", "addCalendarShare": "Adicionar acesso", "calendarShareFreeBusy": "Apenas livre/ocupado", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "Não foi possível verificar a identidade desta ligação antiga ao Google. Os calendários, tarefas e alterações pendentes são preservados. Adicione uma ligação Google separada para sincronizar em segurança. As alterações pendentes permanecerão na conta original.", - "addSeparateGoogleAccount": "Adicionar ligação Google separada" + "addSeparateGoogleAccount": "Adicionar ligação Google separada", + "seriesPatternChangeConfirmation": "Esta alteração da recorrência pode adicionar ou remover datas.\n\nAtual: {before}\nProposta: {after}\n\nGuardar esta recorrência?", + "connectedAccounts": "Contas ligadas", + "contactsIndependentAccounts": "Contas de contactos independentes", + "contactsNextcloudReuse": "As sugestões reutilizam as credenciais desta conta através da integração de contactos só de leitura.", + "contactsRetrySync": "Repetir sincronização de contactos", + "contactsPreferenceFailed": "Não foi possível guardar a preferência da origem de contactos.", + "contactsSuggestionsDisabled": "Sugestões de contactos desativadas", + "contactsSyncFailed": "Não foi possível sincronizar os contactos. Tente novamente ou volte a ligar a conta se a autorização expirou.", + "registrationRecoveryDescription": "O registo guardado ou a associação às credenciais está indisponível ou foi rejeitado. Substitua explicitamente o registo desta conta para a recuperar; os dados são preservados.", + "revalidateAccount": "Revalidar ligação", + "openSharedPrimaryDescription": "Abre o calendário principal do proprietário indicado. Outros calendários partilhados são detetados na sincronização normal da conta.", + "showInBusyMax": "Mostrar no BusyMax", + "showInGoogleCalendarList": "Mostrar na lista de calendários do Google", + "googleVisibilityHelp": "A seleção do BusyMax é local. A visibilidade do Google altera a sua lista no Google; calendários ocultos não podem ser apresentados no BusyMax.", + "primaryCalendarVisibilityExplanation": "O calendário principal do Google permanece na lista.", + "accountsLoading": "A carregar contas…", + "accountsLoadFailed": "Não foi possível carregar as contas. Volte a abrir Contas.", + "collectionsLoading": "A carregar coleções…", + "collectionsLoadFailed": "Não foi possível carregar as coleções. Os dados em cache são preservados; repita a sincronização.", + "collectionsNotDiscovered": "A descoberta não terminou. Atualize ou volte a ligar esta conta.", + "collectionsEmpty": "Não foram encontradas coleções.", + "accountReadyToSync": "Pronto para sincronizar", + "accountSyncProblem": "A sincronização requer atenção", + "accountServiceUnreachable": "Não foi possível contactar o serviço", + "accountSyncDetails": "Detalhes de sincronização", + "accountReconnectStatus": "É necessário voltar a ligar", + "accountRegistrationStatus": "O registo requer atenção", + "collectionsUnavailable": "Coleções indisponíveis", + "accountContactSuggestions": "Sugerir convidados a partir dos contactos desta conta", + "accountLoadingSignInSettings": "A carregar as definições de início de sessão…", + "contactsNoAddressBooks": "Nenhum livro de endereços disponível", + "contactsSourcesUnavailable": "Não foi possível carregar os livros de endereços", + "syncNoRecord": "Nenhuma data de sincronização bem-sucedida registada", + "cachedSyncError": "A sincronização falhou; os dados em cache podem estar desatualizados.", + "calendarRemotePending": "Alteração de visibilidade no Google pendente", + "calendarRemoteFailed": "A alteração de visibilidade no Google falhou. É apresentado o último estado confirmado do Google. O BusyMax continua a seguir a alteração pendente. Tente novamente ou descarte a alteração.", + "discardCalendarSettingsConfirmation": "Descartar as alterações pendentes de «{calendar}»?\n\nEstas definições regressarão aos valores anteriores:\n{settings}\n\nA sua seleção de calendários no BusyMax será mantida. Não serão enviadas alterações à Google.", + "sharedCalendarUnavailable": "Acesso indisponível; a mostrar dados em cache. Repita a sincronização ou remova o calendário da sua lista.", + "invalidCollectionColor": "Introduza uma cor hexadecimal de seis ou oito dígitos, começando por #.", + "invalidCollectionOrder": "Introduza um número inteiro.", + "invalidCollectionTimezone": "Introduza um documento de fuso horário válido com VTIMEZONE e TZID.", + "invalidCollectionValue": "Corrija este valor antes de guardar.", + "accountAlreadyConnected": "Esta conta já está ligada. Utilize a ação para voltar a ligar.", + "nextcloudRemovalAuthorizationDescription": "O BusyMax tentará também revogar a palavra-passe de aplicação Nextcloud desta conta. A remoção local pode ser concluída mesmo que a revogação falhe." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4edfd4da..e631b9da 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Не удалось загрузить учётные записи контактов", "contactsTitle": "Контакты", "contactsLinkedDescription": "Использовать кэшированные контакты этой учётной записи при добавлении гостей события.", "contactsEnableSuggestions": "Включить предложения контактов", @@ -1092,7 +1093,7 @@ "windowMinimize": "Свернуть", "windowMaximize": "Развернуть", "windowRestore": "Восстановить", - "openSharedCalendar": "Открыть общий календарь", + "openSharedCalendar": "Открыть общий основной календарь", "calendarOwnerEmail": "Адрес электронной почты владельца календаря", "addCalendarShare": "Добавить доступ", "calendarShareFreeBusy": "Только занятость", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "Не удалось подтвердить личность владельца этого старого подключения Google. Календари, задачи и ожидающие изменения сохранены. Добавьте отдельное подключение Google для безопасной синхронизации. Ожидающие изменения останутся в исходном аккаунте.", - "addSeparateGoogleAccount": "Добавить отдельное подключение Google" + "addSeparateGoogleAccount": "Добавить отдельное подключение Google", + "seriesPatternChangeConfirmation": "Изменение правила повторения может добавить или удалить даты.\n\nСейчас: {before}\nПредложено: {after}\n\nСохранить это правило?", + "connectedAccounts": "Подключённые аккаунты", + "contactsIndependentAccounts": "Независимые аккаунты контактов", + "contactsNextcloudReuse": "Подсказки используют существующие учётные данные этого аккаунта через интеграцию контактов только для чтения.", + "contactsRetrySync": "Повторить синхронизацию контактов", + "contactsPreferenceFailed": "Не удалось сохранить настройку источника контактов.", + "contactsSuggestionsDisabled": "Подсказки контактов отключены", + "contactsSyncFailed": "Не удалось синхронизировать контакты. Повторите попытку или переподключите аккаунт, если авторизация истекла.", + "registrationRecoveryDescription": "Сохранённая регистрация или привязка учётных данных недоступна либо отклонена. Для восстановления явно замените регистрацию этого аккаунта; данные сохранятся.", + "revalidateAccount": "Проверить подключение", + "openSharedPrimaryDescription": "Открывает основной календарь указанного владельца. Другие общие календари обнаруживаются при обычной синхронизации аккаунта.", + "showInBusyMax": "Показывать в BusyMax", + "showInGoogleCalendarList": "Показывать в списке календарей Google", + "googleVisibilityHelp": "Выбор BusyMax действует локально. Видимость Google меняет ваш список в Google; скрытые календари нельзя показать в BusyMax.", + "primaryCalendarVisibilityExplanation": "Основной календарь Google остаётся в списке.", + "accountsLoading": "Загрузка аккаунтов…", + "accountsLoadFailed": "Не удалось загрузить аккаунты. Откройте раздел «Аккаунты» заново.", + "collectionsLoading": "Загрузка коллекций…", + "collectionsLoadFailed": "Не удалось загрузить коллекции. Кэш сохранён; повторите синхронизацию.", + "collectionsNotDiscovered": "Обнаружение ещё не завершено. Обновите или переподключите аккаунт.", + "collectionsEmpty": "Коллекции не найдены.", + "accountReadyToSync": "Готово к синхронизации", + "accountSyncProblem": "Синхронизация требует внимания", + "accountServiceUnreachable": "Не удалось связаться с сервисом", + "accountSyncDetails": "Подробности синхронизации", + "accountReconnectStatus": "Требуется повторное подключение", + "accountRegistrationStatus": "Регистрация приложения требует внимания", + "collectionsUnavailable": "Коллекции недоступны", + "accountContactSuggestions": "Предлагать гостей из контактов этого аккаунта", + "accountLoadingSignInSettings": "Загрузка настроек входа…", + "contactsNoAddressBooks": "Нет доступных адресных книг", + "contactsSourcesUnavailable": "Не удалось загрузить адресные книги", + "syncNoRecord": "Время успешной синхронизации не записано", + "cachedSyncError": "Сбой синхронизации; данные в кэше могут быть устаревшими.", + "calendarRemotePending": "Ожидается изменение видимости в Google", + "calendarRemoteFailed": "Не удалось изменить видимость в Google. Показано последнее подтверждённое состояние Google. BusyMax по-прежнему учитывает ожидающее изменение. Повторите попытку или отмените изменение.", + "discardCalendarSettingsConfirmation": "Отменить ожидающие изменения календаря «{calendar}»?\n\nБудут восстановлены предыдущие значения этих настроек:\n{settings}\n\nВыбор календарей в BusyMax сохранится. Изменения не будут отправлены в Google.", + "sharedCalendarUnavailable": "Нет доступа; показаны данные из кэша. Повторите синхронизацию или удалите календарь из своего списка.", + "invalidCollectionColor": "Введите шестизначный или восьмизначный шестнадцатеричный цвет, начиная с #.", + "invalidCollectionOrder": "Введите целое число.", + "invalidCollectionTimezone": "Введите корректный документ часового пояса с VTIMEZONE и TZID.", + "invalidCollectionValue": "Исправьте это значение перед сохранением.", + "accountAlreadyConnected": "Этот аккаунт уже подключён. Используйте его действие переподключения.", + "nextcloudRemovalAuthorizationDescription": "BusyMax также попытается отозвать пароль приложения Nextcloud этого аккаунта. Локальное удаление может завершиться, даже если отзыв не удался." } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 74720c74..c8525300 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "Không thể tải các tài khoản danh bạ", "contactsTitle": "Danh bạ", "contactsLinkedDescription": "Dùng các liên hệ đã lưu đệm của tài khoản này khi thêm khách vào sự kiện.", "contactsEnableSuggestions": "Bật gợi ý liên hệ", @@ -1092,7 +1093,7 @@ "windowMinimize": "Thu nhỏ", "windowMaximize": "Phóng to", "windowRestore": "Khôi phục", - "openSharedCalendar": "Mở lịch được chia sẻ", + "openSharedCalendar": "Mở lịch chính được chia sẻ", "calendarOwnerEmail": "Email của chủ sở hữu lịch", "addCalendarShare": "Thêm quyền truy cập", "calendarShareFreeBusy": "Chỉ xem bận/rảnh", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "Không thể xác minh danh tính của kết nối Google cũ này. Lịch, công việc và thay đổi đang chờ được giữ lại. Thêm một kết nối Google riêng để đồng bộ an toàn. Thay đổi đang chờ vẫn thuộc tài khoản gốc.", - "addSeparateGoogleAccount": "Thêm kết nối Google riêng" + "addSeparateGoogleAccount": "Thêm kết nối Google riêng", + "seriesPatternChangeConfirmation": "Thay đổi kiểu lặp có thể thêm hoặc xóa ngày.\n\nHiện tại: {before}\nĐề xuất: {after}\n\nLưu kiểu lặp này?", + "connectedAccounts": "Tài khoản đã kết nối", + "contactsIndependentAccounts": "Tài khoản danh bạ độc lập", + "contactsNextcloudReuse": "Gợi ý liên hệ dùng lại thông tin xác thực hiện có của tài khoản qua tích hợp danh bạ chỉ đọc.", + "contactsRetrySync": "Thử đồng bộ danh bạ lại", + "contactsPreferenceFailed": "Không thể lưu tùy chọn nguồn danh bạ.", + "contactsSuggestionsDisabled": "Đã tắt gợi ý liên hệ", + "contactsSyncFailed": "Không thể đồng bộ danh bạ. Thử lại hoặc kết nối lại tài khoản nếu quyền đã hết hạn.", + "registrationRecoveryDescription": "Đăng ký đã lưu hoặc liên kết thông tin xác thực không khả dụng hoặc bị từ chối. Hãy thay thế rõ ràng đăng ký của tài khoản này để khôi phục; dữ liệu hiện có được giữ lại.", + "revalidateAccount": "Xác minh lại kết nối", + "openSharedPrimaryDescription": "Mở lịch chính của chủ sở hữu được chỉ định. Các lịch chia sẻ khác được tìm qua đồng bộ tài khoản thông thường.", + "showInBusyMax": "Hiện trong BusyMax", + "showInGoogleCalendarList": "Hiện trong danh sách lịch Google", + "googleVisibilityHelp": "Lựa chọn BusyMax là cục bộ. Chế độ hiển thị Google thay đổi danh sách trên Google; lịch bị ẩn không thể hiển thị trong BusyMax.", + "primaryCalendarVisibilityExplanation": "Lịch chính Google luôn nằm trong danh sách.", + "accountsLoading": "Đang tải tài khoản…", + "accountsLoadFailed": "Không thể tải tài khoản. Hãy mở lại Tài khoản.", + "collectionsLoading": "Đang tải bộ sưu tập…", + "collectionsLoadFailed": "Không thể tải bộ sưu tập. Dữ liệu đệm được giữ; hãy thử đồng bộ lại.", + "collectionsNotDiscovered": "Chưa hoàn tất phát hiện. Hãy làm mới hoặc kết nối lại tài khoản.", + "collectionsEmpty": "Không tìm thấy bộ sưu tập.", + "accountReadyToSync": "Sẵn sàng đồng bộ", + "accountSyncProblem": "Đồng bộ cần được xử lý", + "accountServiceUnreachable": "Không thể kết nối với dịch vụ", + "accountSyncDetails": "Chi tiết đồng bộ", + "accountReconnectStatus": "Cần kết nối lại", + "accountRegistrationStatus": "Đăng ký ứng dụng cần được xử lý", + "collectionsUnavailable": "Bộ sưu tập không khả dụng", + "accountContactSuggestions": "Đề xuất khách từ danh bạ của tài khoản này", + "accountLoadingSignInSettings": "Đang tải cài đặt đăng nhập…", + "contactsNoAddressBooks": "Không có sổ địa chỉ nào khả dụng", + "contactsSourcesUnavailable": "Không thể tải sổ địa chỉ", + "syncNoRecord": "Chưa ghi nhận thời điểm đồng bộ thành công", + "cachedSyncError": "Đồng bộ thất bại; dữ liệu đệm có thể đã cũ.", + "calendarRemotePending": "Đang chờ đổi chế độ hiển thị Google", + "calendarRemoteFailed": "Thay đổi khả năng hiển thị trên Google không thành công. Trạng thái Google được xác nhận gần nhất đang hiển thị. BusyMax vẫn áp dụng thay đổi đang chờ. Hãy thử lại hoặc hủy thay đổi.", + "discardCalendarSettingsConfirmation": "Hủy các thay đổi đang chờ của “{calendar}”?\n\nCác cài đặt sau sẽ trở về giá trị trước đó:\n{settings}\n\nLựa chọn lịch trong BusyMax của bạn được giữ nguyên. Không có thay đổi nào được gửi đến Google.", + "sharedCalendarUnavailable": "Không thể truy cập; đang hiện dữ liệu đệm. Thử đồng bộ lại hoặc xóa khỏi danh sách lịch.", + "invalidCollectionColor": "Nhập màu thập lục phân gồm sáu hoặc tám chữ số, bắt đầu bằng #.", + "invalidCollectionOrder": "Nhập số nguyên.", + "invalidCollectionTimezone": "Nhập tài liệu múi giờ lịch hợp lệ chứa VTIMEZONE và TZID.", + "invalidCollectionValue": "Sửa giá trị này trước khi lưu.", + "accountAlreadyConnected": "Tài khoản này đã kết nối. Hãy dùng thao tác kết nối lại của tài khoản.", + "nextcloudRemovalAuthorizationDescription": "BusyMax cũng sẽ thử thu hồi mật khẩu ứng dụng Nextcloud của tài khoản này. Việc xóa cục bộ vẫn có thể thành công dù thu hồi thất bại." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f7cecdc5..5e4672d4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "无法加载联系人账户", "contactsTitle": "联系人", "contactsLinkedDescription": "添加日程参与者时使用此账号缓存的联系人。", "contactsEnableSuggestions": "启用联系人建议", @@ -1092,7 +1093,7 @@ "windowMinimize": "最小化", "windowMaximize": "最大化", "windowRestore": "还原", - "openSharedCalendar": "打开共享日历", + "openSharedCalendar": "打开共享主日历", "calendarOwnerEmail": "日历所有者的电子邮件地址", "addCalendarShare": "添加访问权限", "calendarShareFreeBusy": "仅查看忙闲", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "无法验证此旧 Google 连接的账号身份。日历、任务和待处理的更改已保留。请添加独立的 Google 连接以安全同步。待处理的更改将保留在原账号中。", - "addSeparateGoogleAccount": "添加独立的 Google 连接" + "addSeparateGoogleAccount": "添加独立的 Google 连接", + "seriesPatternChangeConfirmation": "更改重复规则可能会增加或移除日期。\n\n当前:{before}\n建议:{after}\n\n保存此规则吗?", + "connectedAccounts": "已连接的账户", + "contactsIndependentAccounts": "独立联系人账户", + "contactsNextcloudReuse": "联系人建议通过只读联系人集成重复使用此账户现有的凭据。", + "contactsRetrySync": "重试联系人同步", + "contactsPreferenceFailed": "无法保存联系人来源偏好。", + "contactsSuggestionsDisabled": "联系人建议已停用", + "contactsSyncFailed": "无法同步联系人。请重试,或在授权过期时重新连接账户。", + "registrationRecoveryDescription": "保存的应用注册或凭据绑定不可用或被拒绝。请明确替换此账户的注册以恢复连接;现有数据会保留。", + "revalidateAccount": "重新验证连接", + "openSharedPrimaryDescription": "打开指定所有者的主日历。其他共享日历通过普通账户同步发现。", + "showInBusyMax": "在 BusyMax 中显示", + "showInGoogleCalendarList": "在 Google 日历列表中显示", + "googleVisibilityHelp": "BusyMax 的选择是本地设置。Google 可见性会更改 Google 上的日历列表;隐藏的日历无法在 BusyMax 中显示。", + "primaryCalendarVisibilityExplanation": "Google 主日历会保留在日历列表中。", + "accountsLoading": "正在加载账户…", + "accountsLoadFailed": "无法加载账户。请重新打开“账户”。", + "collectionsLoading": "正在加载集合…", + "collectionsLoadFailed": "无法加载集合。缓存数据已保留;请重试同步。", + "collectionsNotDiscovered": "发现尚未完成。请刷新或重新连接此账户。", + "collectionsEmpty": "未找到集合。", + "accountReadyToSync": "已准备好同步", + "accountSyncProblem": "同步需要处理", + "accountServiceUnreachable": "无法连接到服务", + "accountSyncDetails": "同步详情", + "accountReconnectStatus": "需要重新连接", + "accountRegistrationStatus": "应用注册需要处理", + "collectionsUnavailable": "集合不可用", + "accountContactSuggestions": "从此账户的联系人中推荐访客", + "accountLoadingSignInSettings": "正在加载登录设置…", + "contactsNoAddressBooks": "没有可用的通讯录", + "contactsSourcesUnavailable": "无法加载通讯录", + "syncNoRecord": "没有成功同步时间记录", + "cachedSyncError": "同步失败;缓存数据可能已过时。", + "calendarRemotePending": "Google 可见性更改待处理", + "calendarRemoteFailed": "Google 显示设置更改失败。现显示 Google 上次确认的状态。BusyMax 仍遵循待处理的更改。请重试或放弃该更改。", + "discardCalendarSettingsConfirmation": "要放弃“{calendar}”的待处理更改吗?\n\n以下设置将恢复为之前的值:\n{settings}\n\nBusyMax 中的日历选择将保留。不会向 Google 发送任何更改。", + "sharedCalendarUnavailable": "无法访问;正在显示缓存数据。请重试同步或从日历列表中移除。", + "invalidCollectionColor": "请输入以 # 开头的六位或八位十六进制颜色。", + "invalidCollectionOrder": "请输入整数。", + "invalidCollectionTimezone": "请输入包含 VTIMEZONE 和 TZID 的有效日历时区文档。", + "invalidCollectionValue": "保存前请修正此值。", + "accountAlreadyConnected": "此账户已连接。请使用该账户的“重新连接”操作。", + "nextcloudRemovalAuthorizationDescription": "BusyMax 还会尝试撤销此账户的 Nextcloud 应用密码。即使撤销失败,本地移除也可以成功。" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 253a2dd3..9fbf538f 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "无法加载联系人账户", "contactsTitle": "联系人", "contactsLinkedDescription": "添加日程参与者时使用此账号缓存的联系人。", "contactsEnableSuggestions": "启用联系人建议", @@ -1092,7 +1093,7 @@ "windowMinimize": "最小化", "windowMaximize": "最大化", "windowRestore": "还原", - "openSharedCalendar": "打开共享日历", + "openSharedCalendar": "打开共享主日历", "calendarOwnerEmail": "日历所有者的电子邮件地址", "addCalendarShare": "添加访问权限", "calendarShareFreeBusy": "仅查看忙闲", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "无法验证此旧 Google 连接的账号身份。日历、任务和待处理的更改已保留。请添加独立的 Google 连接以安全同步。待处理的更改将保留在原账号中。", - "addSeparateGoogleAccount": "添加独立的 Google 连接" + "addSeparateGoogleAccount": "添加独立的 Google 连接", + "seriesPatternChangeConfirmation": "更改重复规则可能会增加或移除日期。\n\n当前:{before}\n建议:{after}\n\n保存此规则吗?", + "connectedAccounts": "已连接的账户", + "contactsIndependentAccounts": "独立联系人账户", + "contactsNextcloudReuse": "联系人建议通过只读联系人集成重复使用此账户现有的凭据。", + "contactsRetrySync": "重试联系人同步", + "contactsPreferenceFailed": "无法保存联系人来源偏好。", + "contactsSuggestionsDisabled": "联系人建议已停用", + "contactsSyncFailed": "无法同步联系人。请重试,或在授权过期时重新连接账户。", + "registrationRecoveryDescription": "保存的应用注册或凭据绑定不可用或被拒绝。请明确替换此账户的注册以恢复连接;现有数据会保留。", + "revalidateAccount": "重新验证连接", + "openSharedPrimaryDescription": "打开指定所有者的主日历。其他共享日历通过普通账户同步发现。", + "showInBusyMax": "在 BusyMax 中显示", + "showInGoogleCalendarList": "在 Google 日历列表中显示", + "googleVisibilityHelp": "BusyMax 的选择是本地设置。Google 可见性会更改 Google 上的日历列表;隐藏的日历无法在 BusyMax 中显示。", + "primaryCalendarVisibilityExplanation": "Google 主日历会保留在日历列表中。", + "accountsLoading": "正在加载账户…", + "accountsLoadFailed": "无法加载账户。请重新打开“账户”。", + "collectionsLoading": "正在加载集合…", + "collectionsLoadFailed": "无法加载集合。缓存数据已保留;请重试同步。", + "collectionsNotDiscovered": "发现尚未完成。请刷新或重新连接此账户。", + "collectionsEmpty": "未找到集合。", + "accountReadyToSync": "已准备好同步", + "accountSyncProblem": "同步需要处理", + "accountServiceUnreachable": "无法连接到服务", + "accountSyncDetails": "同步详情", + "accountReconnectStatus": "需要重新连接", + "accountRegistrationStatus": "应用注册需要处理", + "collectionsUnavailable": "集合不可用", + "accountContactSuggestions": "从此账户的联系人中推荐访客", + "accountLoadingSignInSettings": "正在加载登录设置…", + "contactsNoAddressBooks": "没有可用的通讯录", + "contactsSourcesUnavailable": "无法加载通讯录", + "syncNoRecord": "没有成功同步时间记录", + "cachedSyncError": "同步失败;缓存数据可能已过时。", + "calendarRemotePending": "Google 可见性更改待处理", + "calendarRemoteFailed": "Google 显示设置更改失败。现显示 Google 上次确认的状态。BusyMax 仍遵循待处理的更改。请重试或放弃该更改。", + "discardCalendarSettingsConfirmation": "要放弃“{calendar}”的待处理更改吗?\n\n以下设置将恢复为之前的值:\n{settings}\n\nBusyMax 中的日历选择将保留。不会向 Google 发送任何更改。", + "sharedCalendarUnavailable": "无法访问;正在显示缓存数据。请重试同步或从日历列表中移除。", + "invalidCollectionColor": "请输入以 # 开头的六位或八位十六进制颜色。", + "invalidCollectionOrder": "请输入整数。", + "invalidCollectionTimezone": "请输入包含 VTIMEZONE 和 TZID 的有效日历时区文档。", + "invalidCollectionValue": "保存前请修正此值。", + "accountAlreadyConnected": "此账户已连接。请使用该账户的“重新连接”操作。", + "nextcloudRemovalAuthorizationDescription": "BusyMax 还会尝试撤销此账户的 Nextcloud 应用密码。即使撤销失败,本地移除也可以成功。" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index d7bf73f3..2c9d02f6 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,5 @@ { + "contactsAccountsUnavailable": "無法載入聯絡人帳號", "contactsTitle": "聯絡人", "contactsLinkedDescription": "新增活動來賓時使用此帳號快取的聯絡人。", "contactsEnableSuggestions": "啟用聯絡人建議", @@ -1092,7 +1093,7 @@ "windowMinimize": "最小化", "windowMaximize": "最大化", "windowRestore": "還原", - "openSharedCalendar": "開啟共用行事曆", + "openSharedCalendar": "開啟共用主要行事曆", "calendarOwnerEmail": "行事曆擁有者的電子郵件地址", "addCalendarShare": "新增存取權", "calendarShareFreeBusy": "僅顯示忙閒", @@ -1296,5 +1297,49 @@ } }, "googleIdentityUnverified": "無法驗證此舊 Google 連線的帳號身分。行事曆、待辦事項和待處理的變更已保留。請新增獨立的 Google 連線以安全同步。待處理的變更將保留在原帳號中。", - "addSeparateGoogleAccount": "新增獨立的 Google 連線" + "addSeparateGoogleAccount": "新增獨立的 Google 連線", + "seriesPatternChangeConfirmation": "變更重複規則可能會增加或移除日期。\n\n目前:{before}\n建議:{after}\n\n要儲存此規則嗎?", + "connectedAccounts": "已連線的帳戶", + "contactsIndependentAccounts": "獨立聯絡人帳戶", + "contactsNextcloudReuse": "聯絡人建議透過唯讀聯絡人整合重複使用此帳戶現有的憑證。", + "contactsRetrySync": "重試聯絡人同步", + "contactsPreferenceFailed": "無法儲存聯絡人來源偏好。", + "contactsSuggestionsDisabled": "聯絡人建議已停用", + "contactsSyncFailed": "無法同步聯絡人。請重試,或在授權過期時重新連線帳戶。", + "registrationRecoveryDescription": "儲存的應用程式註冊或憑證繫結無法使用或遭拒。請明確取代此帳戶的註冊以恢復連線;現有資料會保留。", + "revalidateAccount": "重新驗證連線", + "openSharedPrimaryDescription": "開啟指定擁有者的主要行事曆。其他共用行事曆透過一般帳戶同步尋找。", + "showInBusyMax": "在 BusyMax 中顯示", + "showInGoogleCalendarList": "在 Google 行事曆清單中顯示", + "googleVisibilityHelp": "BusyMax 的選擇是本機設定。Google 可見性會變更 Google 上的清單;隱藏的行事曆無法在 BusyMax 中顯示。", + "primaryCalendarVisibilityExplanation": "Google 主要行事曆會保留在清單中。", + "accountsLoading": "正在載入帳戶…", + "accountsLoadFailed": "無法載入帳戶。請重新開啟「帳戶」。", + "collectionsLoading": "正在載入集合…", + "collectionsLoadFailed": "無法載入集合。快取資料已保留;請重試同步。", + "collectionsNotDiscovered": "探索尚未完成。請重新整理或重新連線此帳戶。", + "collectionsEmpty": "找不到集合。", + "accountReadyToSync": "已準備好同步", + "accountSyncProblem": "同步需要處理", + "accountServiceUnreachable": "無法連線至服務", + "accountSyncDetails": "同步詳細資料", + "accountReconnectStatus": "需要重新連線", + "accountRegistrationStatus": "應用程式註冊需要處理", + "collectionsUnavailable": "集合無法使用", + "accountContactSuggestions": "從此帳戶的聯絡人中建議訪客", + "accountLoadingSignInSettings": "正在載入登入設定…", + "contactsNoAddressBooks": "沒有可用的通訊錄", + "contactsSourcesUnavailable": "無法載入通訊錄", + "syncNoRecord": "沒有成功同步時間的記錄", + "cachedSyncError": "同步失敗;快取資料可能已過時。", + "calendarRemotePending": "Google 可見性變更待處理", + "calendarRemoteFailed": "Google 顯示設定變更失敗。目前顯示 Google 上次確認的狀態。BusyMax 仍遵循待處理的變更。請重試或捨棄該變更。", + "discardCalendarSettingsConfirmation": "要捨棄「{calendar}」的待處理變更嗎?\n\n以下設定將還原為先前的值:\n{settings}\n\nBusyMax 中的行事曆選擇將會保留。不會向 Google 傳送任何變更。", + "sharedCalendarUnavailable": "無法存取;正在顯示快取資料。請重試同步或從行事曆清單移除。", + "invalidCollectionColor": "請輸入以 # 開頭的六位或八位十六進位色彩。", + "invalidCollectionOrder": "請輸入整數。", + "invalidCollectionTimezone": "請輸入包含 VTIMEZONE 和 TZID 的有效行事曆時區文件。", + "invalidCollectionValue": "儲存前請修正此值。", + "accountAlreadyConnected": "此帳戶已連線。請使用該帳戶的「重新連線」操作。", + "nextcloudRemovalAuthorizationDescription": "BusyMax 也會嘗試撤銷此帳戶的 Nextcloud 應用程式密碼。即使撤銷失敗,本機移除仍可成功。" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 62f99895..bd6001be 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -135,6 +135,12 @@ abstract class AppLocalizations { Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), ]; + /// No description provided for @contactsAccountsUnavailable. + /// + /// In en, this message translates to: + /// **'Could not load contact accounts'** + String get contactsAccountsUnavailable; + /// No description provided for @launchAtLoginManagedExternally. /// /// In en, this message translates to: @@ -5021,7 +5027,7 @@ abstract class AppLocalizations { /// No description provided for @openSharedCalendar. /// /// In en, this message translates to: - /// **'Open shared calendar'** + /// **'Open shared primary calendar'** String get openSharedCalendar; /// No description provided for @manageCalendarSharing. @@ -6035,6 +6041,270 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Add a separate Google connection'** String get addSeparateGoogleAccount; + + /// Explicit approval of an ordinary series date edit that changes recurrence semantics. Both patterns include their termination condition. + /// + /// In en, this message translates to: + /// **'This changes the recurrence pattern and may add or remove dates.\n\nCurrent: {before}\nProposed: {after}\n\nSave this pattern?'** + String seriesPatternChangeConfirmation(String before, String after); + + /// No description provided for @connectedAccounts. + /// + /// In en, this message translates to: + /// **'Connected accounts'** + String get connectedAccounts; + + /// No description provided for @contactsIndependentAccounts. + /// + /// In en, this message translates to: + /// **'Independent contact accounts'** + String get contactsIndependentAccounts; + + /// No description provided for @contactsNextcloudReuse. + /// + /// In en, this message translates to: + /// **'Contact suggestions reuse this account’s existing credential through the read-only contacts integration.'** + String get contactsNextcloudReuse; + + /// No description provided for @contactsRetrySync. + /// + /// In en, this message translates to: + /// **'Retry contacts sync'** + String get contactsRetrySync; + + /// No description provided for @contactsPreferenceFailed. + /// + /// In en, this message translates to: + /// **'Could not save contact source preference'** + String get contactsPreferenceFailed; + + /// No description provided for @contactsSuggestionsDisabled. + /// + /// In en, this message translates to: + /// **'Contact suggestions disabled'** + String get contactsSuggestionsDisabled; + + /// No description provided for @contactsSyncFailed. + /// + /// In en, this message translates to: + /// **'Contacts could not sync. Retry, or reconnect if authorization has expired.'** + String get contactsSyncFailed; + + /// No description provided for @registrationRecoveryDescription. + /// + /// In en, this message translates to: + /// **'The saved registration or its credential binding is unavailable or rejected. Explicitly replace the registration for this account to recover; existing data is preserved.'** + String get registrationRecoveryDescription; + + /// No description provided for @revalidateAccount. + /// + /// In en, this message translates to: + /// **'Revalidate connection'** + String get revalidateAccount; + + /// No description provided for @openSharedPrimaryDescription. + /// + /// In en, this message translates to: + /// **'Opens the specified owner’s primary calendar. Other shared calendars are discovered through ordinary account synchronization.'** + String get openSharedPrimaryDescription; + + /// No description provided for @showInBusyMax. + /// + /// In en, this message translates to: + /// **'Show in BusyMax'** + String get showInBusyMax; + + /// No description provided for @showInGoogleCalendarList. + /// + /// In en, this message translates to: + /// **'Show in Google’s calendar list'** + String get showInGoogleCalendarList; + + /// No description provided for @googleVisibilityHelp. + /// + /// In en, this message translates to: + /// **'BusyMax selection is local. Google visibility changes your calendar list on Google; hidden calendars cannot be displayed in BusyMax.'** + String get googleVisibilityHelp; + + /// No description provided for @primaryCalendarVisibilityExplanation. + /// + /// In en, this message translates to: + /// **'Google’s primary calendar stays in its calendar list.'** + String get primaryCalendarVisibilityExplanation; + + /// No description provided for @accountsLoading. + /// + /// In en, this message translates to: + /// **'Loading accounts…'** + String get accountsLoading; + + /// No description provided for @accountsLoadFailed. + /// + /// In en, this message translates to: + /// **'Accounts could not be loaded. Retry opening Accounts.'** + String get accountsLoadFailed; + + /// No description provided for @collectionsLoading. + /// + /// In en, this message translates to: + /// **'Loading collections…'** + String get collectionsLoading; + + /// No description provided for @collectionsLoadFailed. + /// + /// In en, this message translates to: + /// **'Collections could not be loaded. Cached data is retained; retry synchronization.'** + String get collectionsLoadFailed; + + /// No description provided for @collectionsNotDiscovered. + /// + /// In en, this message translates to: + /// **'Discovery has not completed. Refresh or reconnect this account.'** + String get collectionsNotDiscovered; + + /// No description provided for @collectionsEmpty. + /// + /// In en, this message translates to: + /// **'No collections found.'** + String get collectionsEmpty; + + /// No description provided for @accountReadyToSync. + /// + /// In en, this message translates to: + /// **'Ready to sync'** + String get accountReadyToSync; + + /// No description provided for @accountSyncProblem. + /// + /// In en, this message translates to: + /// **'Synchronization needs attention'** + String get accountSyncProblem; + + /// No description provided for @accountServiceUnreachable. + /// + /// In en, this message translates to: + /// **'Could not reach the service'** + String get accountServiceUnreachable; + + /// No description provided for @accountSyncDetails. + /// + /// In en, this message translates to: + /// **'Synchronization details'** + String get accountSyncDetails; + + /// No description provided for @accountReconnectStatus. + /// + /// In en, this message translates to: + /// **'Reconnect required'** + String get accountReconnectStatus; + + /// No description provided for @accountRegistrationStatus. + /// + /// In en, this message translates to: + /// **'Registration needs attention'** + String get accountRegistrationStatus; + + /// No description provided for @collectionsUnavailable. + /// + /// In en, this message translates to: + /// **'Collections unavailable'** + String get collectionsUnavailable; + + /// No description provided for @accountContactSuggestions. + /// + /// In en, this message translates to: + /// **'Suggest guests from this account’s contacts'** + String get accountContactSuggestions; + + /// No description provided for @accountLoadingSignInSettings. + /// + /// In en, this message translates to: + /// **'Loading sign-in settings…'** + String get accountLoadingSignInSettings; + + /// No description provided for @contactsNoAddressBooks. + /// + /// In en, this message translates to: + /// **'No address books available'** + String get contactsNoAddressBooks; + + /// No description provided for @contactsSourcesUnavailable. + /// + /// In en, this message translates to: + /// **'Could not load address books'** + String get contactsSourcesUnavailable; + + /// No description provided for @syncNoRecord. + /// + /// In en, this message translates to: + /// **'No successful sync time recorded'** + String get syncNoRecord; + + /// No description provided for @cachedSyncError. + /// + /// In en, this message translates to: + /// **'Sync failed; cached data may be out of date.'** + String get cachedSyncError; + + /// No description provided for @calendarRemotePending. + /// + /// In en, this message translates to: + /// **'Google visibility change pending'** + String get calendarRemotePending; + + /// No description provided for @calendarRemoteFailed. + /// + /// In en, this message translates to: + /// **'Google visibility change failed. Google’s last confirmed state is shown. BusyMax still follows the pending change. Retry or discard it.'** + String get calendarRemoteFailed; + + /// Confirms discarding the entire queued Google calendar settings patch. calendar is the calendar display name; settings is a localized, newline-separated bulleted list of every affected setting. No provider request is sent, and the local calendar selection is preserved. + /// + /// In en, this message translates to: + /// **'Discard the pending changes to “{calendar}”?\n\nThese settings will return to their previous values:\n{settings}\n\nYour BusyMax calendar selection is kept. No changes will be sent to Google.'** + String discardCalendarSettingsConfirmation(String calendar, String settings); + + /// No description provided for @sharedCalendarUnavailable. + /// + /// In en, this message translates to: + /// **'Access unavailable; showing cached data. Retry synchronization or remove from your calendar list.'** + String get sharedCalendarUnavailable; + + /// No description provided for @invalidCollectionColor. + /// + /// In en, this message translates to: + /// **'Enter a six- or eight-digit hexadecimal color, starting with #.'** + String get invalidCollectionColor; + + /// No description provided for @invalidCollectionOrder. + /// + /// In en, this message translates to: + /// **'Enter a whole number.'** + String get invalidCollectionOrder; + + /// No description provided for @invalidCollectionTimezone. + /// + /// In en, this message translates to: + /// **'Enter a valid calendar timezone document containing VTIMEZONE and TZID.'** + String get invalidCollectionTimezone; + + /// No description provided for @invalidCollectionValue. + /// + /// In en, this message translates to: + /// **'Correct this value before saving.'** + String get invalidCollectionValue; + + /// No description provided for @accountAlreadyConnected. + /// + /// In en, this message translates to: + /// **'This account is already connected. Use its Reconnect action.'** + String get accountAlreadyConnected; + + /// No description provided for @nextcloudRemovalAuthorizationDescription. + /// + /// In en, this message translates to: + /// **'BusyMax will also try to revoke this account’s Nextcloud app password. Local removal can succeed even if that revocation fails.'** + String get nextcloudRemovalAuthorizationDescription; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 5b7084ff..0f473f26 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get contactsAccountsUnavailable => 'تعذّر تحميل حسابات جهات الاتصال'; + @override String get launchAtLoginManagedExternally => 'تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.'; @@ -3070,7 +3073,7 @@ class AppLocalizationsAr extends AppLocalizations { String get searchClearText => 'مسح النص'; @override - String get openSharedCalendar => 'فتح تقويم مشترك'; + String get openSharedCalendar => 'فتح تقويم أساسي مشترك'; @override String get manageCalendarSharing => 'إدارة مشاركة التقويم'; @@ -3646,4 +3649,156 @@ class AppLocalizationsAr extends AppLocalizations { @override String get addSeparateGoogleAccount => 'إضافة اتصال Google منفصل'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'قد تؤدي هذه التغييرات في نمط التكرار إلى إضافة تواريخ أو إزالتها.\n\nالحالي: ⁨$before⁩\nالمقترح: ⁨$after⁩\n\nهل تريد حفظ هذا النمط؟'; + } + + @override + String get connectedAccounts => 'الحسابات المتصلة'; + + @override + String get contactsIndependentAccounts => 'حسابات جهات اتصال مستقلة'; + + @override + String get contactsNextcloudReuse => + 'تعيد اقتراحات جهات الاتصال استخدام بيانات اعتماد هذا الحساب عبر تكامل جهات الاتصال للقراءة فقط.'; + + @override + String get contactsRetrySync => 'إعادة محاولة مزامنة جهات الاتصال'; + + @override + String get contactsPreferenceFailed => 'تعذّر حفظ تفضيل مصدر جهات الاتصال.'; + + @override + String get contactsSuggestionsDisabled => 'اقتراحات جهات الاتصال معطّلة'; + + @override + String get contactsSyncFailed => + 'تعذّرت مزامنة جهات الاتصال. أعد المحاولة أو أعد توصيل الحساب إذا انتهى التفويض.'; + + @override + String get registrationRecoveryDescription => + 'التسجيل المحفوظ أو ارتباط بيانات اعتماده غير متاح أو مرفوض. استبدل تسجيل هذا الحساب صراحةً لاستعادته؛ تُحفظ البيانات الحالية.'; + + @override + String get revalidateAccount => 'إعادة التحقق من الاتصال'; + + @override + String get openSharedPrimaryDescription => + 'يفتح التقويم الأساسي للمالك المحدد. تُكتشف التقاويم المشتركة الأخرى عبر مزامنة الحساب المعتادة.'; + + @override + String get showInBusyMax => 'إظهار في BusyMax'; + + @override + String get showInGoogleCalendarList => 'إظهار في قائمة تقاويم Google'; + + @override + String get googleVisibilityHelp => + 'اختيار BusyMax محلي. يغيّر ظهور Google قائمتك في Google؛ لا يمكن عرض التقاويم المخفية في BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'يبقى تقويم Google الأساسي في قائمته.'; + + @override + String get accountsLoading => 'جارٍ تحميل الحسابات…'; + + @override + String get accountsLoadFailed => 'تعذّر تحميل الحسابات. أعد فتح الحسابات.'; + + @override + String get collectionsLoading => 'جارٍ تحميل المجموعات…'; + + @override + String get collectionsLoadFailed => + 'تعذّر تحميل المجموعات. تُحفظ البيانات المخزنة مؤقتًا؛ أعد محاولة المزامنة.'; + + @override + String get collectionsNotDiscovered => + 'لم يكتمل الاكتشاف. حدّث هذا الحساب أو أعد توصيله.'; + + @override + String get collectionsEmpty => 'لم يُعثر على مجموعات.'; + + @override + String get accountReadyToSync => 'جاهز للمزامنة'; + + @override + String get accountSyncProblem => 'تحتاج المزامنة إلى معالجة'; + + @override + String get accountServiceUnreachable => 'تعذّر الوصول إلى الخدمة'; + + @override + String get accountSyncDetails => 'تفاصيل المزامنة'; + + @override + String get accountReconnectStatus => 'يلزم إعادة الاتصال'; + + @override + String get accountRegistrationStatus => 'يحتاج تسجيل التطبيق إلى معالجة'; + + @override + String get collectionsUnavailable => 'المجموعات غير متاحة'; + + @override + String get accountContactSuggestions => + 'اقتراح مدعوين من جهات اتصال هذا الحساب'; + + @override + String get accountLoadingSignInSettings => 'جارٍ تحميل إعدادات تسجيل الدخول…'; + + @override + String get contactsNoAddressBooks => 'لا توجد دفاتر عناوين متاحة'; + + @override + String get contactsSourcesUnavailable => 'تعذّر تحميل دفاتر العناوين'; + + @override + String get syncNoRecord => 'لم يُسجّل وقت مزامنة ناجحة'; + + @override + String get cachedSyncError => + 'فشلت المزامنة؛ قد تكون البيانات المخزنة مؤقتًا قديمة.'; + + @override + String get calendarRemotePending => 'تغيير ظهور Google قيد الانتظار'; + + @override + String get calendarRemoteFailed => + 'فشل تغيير الظهور في Google. تظهر آخر حالة مؤكدة في Google. ما زال BusyMax يتبع التغيير المعلّق. أعد المحاولة أو تجاهل التغيير.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'هل تريد تجاهل التغييرات المعلّقة على «⁨$calendar⁩»؟\n\nستعود هذه الإعدادات إلى قيمها السابقة:\n⁨$settings⁩\n\nسيُحتفظ باختيار التقويم في BusyMax. لن تُرسل أي تغييرات إلى Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'الوصول غير متاح؛ تُعرض البيانات المخزنة مؤقتًا. أعد المزامنة أو أزل التقويم من قائمتك.'; + + @override + String get invalidCollectionColor => + 'أدخل لونًا سداسيًا عشريًا من ست أو ثماني خانات يبدأ بـ #.'; + + @override + String get invalidCollectionOrder => 'أدخل عددًا صحيحًا.'; + + @override + String get invalidCollectionTimezone => + 'أدخل مستند منطقة زمنية صالحًا للتقويم يحتوي على VTIMEZONE وTZID.'; + + @override + String get invalidCollectionValue => 'صحّح هذه القيمة قبل الحفظ.'; + + @override + String get accountAlreadyConnected => + 'هذا الحساب متصل بالفعل. استخدم إجراء إعادة توصيله.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'سيحاول BusyMax أيضًا إلغاء كلمة مرور تطبيق Nextcloud لهذا الحساب. قد تنجح الإزالة المحلية حتى إذا فشل الإلغاء.'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 339c6098..870723c6 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -13,6 +13,10 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get contactsAccountsUnavailable => + 'Kontaktkonten konnten nicht geladen werden'; + @override String get launchAtLoginManagedExternally => 'BusyMax wurde durch die Autostart-Einstellungen Ihres Desktops gestartet. Entfernen Sie dort den Eintrag, beenden Sie BusyMax und öffnen Sie es erneut, um diesen Schalter zu verwenden.'; @@ -3006,7 +3010,7 @@ class AppLocalizationsDe extends AppLocalizations { String get searchClearText => 'Text löschen'; @override - String get openSharedCalendar => 'Freigegebenen Kalender öffnen'; + String get openSharedCalendar => 'Freigegebenen Hauptkalender öffnen'; @override String get manageCalendarSharing => 'Kalenderfreigabe verwalten'; @@ -3597,4 +3601,163 @@ class AppLocalizationsDe extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Separate Google-Verbindung hinzufügen'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Dies ändert das Wiederholungsmuster und kann Termine hinzufügen oder entfernen.\n\nAktuell: $before\nVorschlag: $after\n\nDieses Muster speichern?'; + } + + @override + String get connectedAccounts => 'Verbundene Konten'; + + @override + String get contactsIndependentAccounts => 'Unabhängige Kontaktkonten'; + + @override + String get contactsNextcloudReuse => + 'Kontaktvorschläge verwenden die vorhandenen Zugangsdaten dieses Kontos über die schreibgeschützte Kontaktintegration.'; + + @override + String get contactsRetrySync => 'Kontaktsynchronisierung wiederholen'; + + @override + String get contactsPreferenceFailed => + 'Die Kontaktquelleneinstellung konnte nicht gespeichert werden.'; + + @override + String get contactsSuggestionsDisabled => 'Kontaktvorschläge deaktiviert'; + + @override + String get contactsSyncFailed => + 'Kontakte konnten nicht synchronisiert werden. Erneut versuchen oder bei abgelaufener Autorisierung das Konto neu verbinden.'; + + @override + String get registrationRecoveryDescription => + 'Die gespeicherte Registrierung oder ihre Zugangsdatenbindung fehlt oder wurde abgelehnt. Ersetzen Sie die Registrierung dieses Kontos ausdrücklich, um den Zugriff wiederherzustellen. Vorhandene Daten bleiben erhalten.'; + + @override + String get revalidateAccount => 'Verbindung erneut prüfen'; + + @override + String get openSharedPrimaryDescription => + 'Öffnet den Hauptkalender des angegebenen Besitzers. Andere freigegebene Kalender werden bei der normalen Kontosynchronisierung gefunden.'; + + @override + String get showInBusyMax => 'In BusyMax anzeigen'; + + @override + String get showInGoogleCalendarList => 'In Googles Kalenderliste anzeigen'; + + @override + String get googleVisibilityHelp => + 'Die BusyMax-Auswahl gilt lokal. Die Google-Sichtbarkeit ändert Ihre Kalenderliste bei Google; ausgeblendete Kalender können in BusyMax nicht angezeigt werden.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Der Google-Hauptkalender bleibt in der Kalenderliste.'; + + @override + String get accountsLoading => 'Konten werden geladen…'; + + @override + String get accountsLoadFailed => + 'Konten konnten nicht geladen werden. Öffnen Sie Konten erneut.'; + + @override + String get collectionsLoading => 'Sammlungen werden geladen…'; + + @override + String get collectionsLoadFailed => + 'Sammlungen konnten nicht geladen werden. Zwischengespeicherte Daten bleiben erhalten. Synchronisierung erneut versuchen.'; + + @override + String get collectionsNotDiscovered => + 'Die Erkennung ist noch nicht abgeschlossen. Konto aktualisieren oder neu verbinden.'; + + @override + String get collectionsEmpty => 'Keine Sammlungen gefunden.'; + + @override + String get accountReadyToSync => 'Bereit zur Synchronisierung'; + + @override + String get accountSyncProblem => 'Synchronisierung benötigt Aufmerksamkeit'; + + @override + String get accountServiceUnreachable => 'Dienst nicht erreichbar'; + + @override + String get accountSyncDetails => 'Synchronisierungsdetails'; + + @override + String get accountReconnectStatus => 'Erneute Verbindung erforderlich'; + + @override + String get accountRegistrationStatus => + 'App-Registrierung benötigt Aufmerksamkeit'; + + @override + String get collectionsUnavailable => 'Sammlungen nicht verfügbar'; + + @override + String get accountContactSuggestions => + 'Gäste aus den Kontakten dieses Kontos vorschlagen'; + + @override + String get accountLoadingSignInSettings => + 'Anmeldeeinstellungen werden geladen…'; + + @override + String get contactsNoAddressBooks => 'Keine Adressbücher verfügbar'; + + @override + String get contactsSourcesUnavailable => + 'Adressbücher konnten nicht geladen werden'; + + @override + String get syncNoRecord => + 'Kein erfolgreicher Synchronisierungszeitpunkt erfasst'; + + @override + String get cachedSyncError => + 'Synchronisierung fehlgeschlagen; zwischengespeicherte Daten können veraltet sein.'; + + @override + String get calendarRemotePending => 'Google-Sichtbarkeitsänderung ausstehend'; + + @override + String get calendarRemoteFailed => + 'Die Sichtbarkeitsänderung bei Google ist fehlgeschlagen. Der letzte bestätigte Google-Status wird angezeigt. BusyMax folgt weiterhin der ausstehenden Änderung. Erneut versuchen oder verwerfen.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Ausstehende Änderungen an „$calendar“ verwerfen?\n\nDiese Einstellungen werden auf ihre vorherigen Werte zurückgesetzt:\n$settings\n\nDeine Kalenderauswahl in BusyMax bleibt erhalten. Es werden keine Änderungen an Google gesendet.'; + } + + @override + String get sharedCalendarUnavailable => + 'Zugriff nicht verfügbar; zwischengespeicherte Daten werden angezeigt. Synchronisierung wiederholen oder aus der Kalenderliste entfernen.'; + + @override + String get invalidCollectionColor => + 'Geben Sie eine sechs- oder achtstellige Hexadezimalfarbe mit # ein.'; + + @override + String get invalidCollectionOrder => 'Geben Sie eine ganze Zahl ein.'; + + @override + String get invalidCollectionTimezone => + 'Geben Sie ein gültiges Kalenderzeitzonendokument mit VTIMEZONE und TZID ein.'; + + @override + String get invalidCollectionValue => + 'Korrigieren Sie diesen Wert vor dem Speichern.'; + + @override + String get accountAlreadyConnected => + 'Dieses Konto ist bereits verbunden. Verwenden Sie die Aktion zum erneuten Verbinden.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax versucht außerdem, das Nextcloud-App-Passwort dieses Kontos zu widerrufen. Die lokale Entfernung kann auch bei fehlgeschlagenem Widerruf erfolgreich sein.'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 9b70309e..4d4d76ec 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get contactsAccountsUnavailable => 'Could not load contact accounts'; + @override String get launchAtLoginManagedExternally => 'BusyMax was launched by your desktop\'s startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.'; @@ -3038,7 +3041,7 @@ class AppLocalizationsEn extends AppLocalizations { String get searchClearText => 'Clear text'; @override - String get openSharedCalendar => 'Open shared calendar'; + String get openSharedCalendar => 'Open shared primary calendar'; @override String get manageCalendarSharing => 'Manage calendar sharing'; @@ -3618,4 +3621,157 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Add a separate Google connection'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'This changes the recurrence pattern and may add or remove dates.\n\nCurrent: $before\nProposed: $after\n\nSave this pattern?'; + } + + @override + String get connectedAccounts => 'Connected accounts'; + + @override + String get contactsIndependentAccounts => 'Independent contact accounts'; + + @override + String get contactsNextcloudReuse => + 'Contact suggestions reuse this account’s existing credential through the read-only contacts integration.'; + + @override + String get contactsRetrySync => 'Retry contacts sync'; + + @override + String get contactsPreferenceFailed => + 'Could not save contact source preference'; + + @override + String get contactsSuggestionsDisabled => 'Contact suggestions disabled'; + + @override + String get contactsSyncFailed => + 'Contacts could not sync. Retry, or reconnect if authorization has expired.'; + + @override + String get registrationRecoveryDescription => + 'The saved registration or its credential binding is unavailable or rejected. Explicitly replace the registration for this account to recover; existing data is preserved.'; + + @override + String get revalidateAccount => 'Revalidate connection'; + + @override + String get openSharedPrimaryDescription => + 'Opens the specified owner’s primary calendar. Other shared calendars are discovered through ordinary account synchronization.'; + + @override + String get showInBusyMax => 'Show in BusyMax'; + + @override + String get showInGoogleCalendarList => 'Show in Google’s calendar list'; + + @override + String get googleVisibilityHelp => + 'BusyMax selection is local. Google visibility changes your calendar list on Google; hidden calendars cannot be displayed in BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Google’s primary calendar stays in its calendar list.'; + + @override + String get accountsLoading => 'Loading accounts…'; + + @override + String get accountsLoadFailed => + 'Accounts could not be loaded. Retry opening Accounts.'; + + @override + String get collectionsLoading => 'Loading collections…'; + + @override + String get collectionsLoadFailed => + 'Collections could not be loaded. Cached data is retained; retry synchronization.'; + + @override + String get collectionsNotDiscovered => + 'Discovery has not completed. Refresh or reconnect this account.'; + + @override + String get collectionsEmpty => 'No collections found.'; + + @override + String get accountReadyToSync => 'Ready to sync'; + + @override + String get accountSyncProblem => 'Synchronization needs attention'; + + @override + String get accountServiceUnreachable => 'Could not reach the service'; + + @override + String get accountSyncDetails => 'Synchronization details'; + + @override + String get accountReconnectStatus => 'Reconnect required'; + + @override + String get accountRegistrationStatus => 'Registration needs attention'; + + @override + String get collectionsUnavailable => 'Collections unavailable'; + + @override + String get accountContactSuggestions => + 'Suggest guests from this account’s contacts'; + + @override + String get accountLoadingSignInSettings => 'Loading sign-in settings…'; + + @override + String get contactsNoAddressBooks => 'No address books available'; + + @override + String get contactsSourcesUnavailable => 'Could not load address books'; + + @override + String get syncNoRecord => 'No successful sync time recorded'; + + @override + String get cachedSyncError => 'Sync failed; cached data may be out of date.'; + + @override + String get calendarRemotePending => 'Google visibility change pending'; + + @override + String get calendarRemoteFailed => + 'Google visibility change failed. Google’s last confirmed state is shown. BusyMax still follows the pending change. Retry or discard it.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Discard the pending changes to “$calendar”?\n\nThese settings will return to their previous values:\n$settings\n\nYour BusyMax calendar selection is kept. No changes will be sent to Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Access unavailable; showing cached data. Retry synchronization or remove from your calendar list.'; + + @override + String get invalidCollectionColor => + 'Enter a six- or eight-digit hexadecimal color, starting with #.'; + + @override + String get invalidCollectionOrder => 'Enter a whole number.'; + + @override + String get invalidCollectionTimezone => + 'Enter a valid calendar timezone document containing VTIMEZONE and TZID.'; + + @override + String get invalidCollectionValue => 'Correct this value before saving.'; + + @override + String get accountAlreadyConnected => + 'This account is already connected. Use its Reconnect action.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax will also try to revoke this account’s Nextcloud app password. Local removal can succeed even if that revocation fails.'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 4d7115a4..ec03f3b8 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -13,6 +13,10 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get contactsAccountsUnavailable => + 'No se pudieron cargar las cuentas de contactos'; + @override String get launchAtLoginManagedExternally => 'BusyMax se inició desde los ajustes de inicio del escritorio. Elimina su entrada allí y luego sal de BusyMax y vuelve a abrirlo para usar este interruptor.'; @@ -3010,7 +3014,7 @@ class AppLocalizationsEs extends AppLocalizations { String get searchClearText => 'Borrar texto'; @override - String get openSharedCalendar => 'Abrir calendario compartido'; + String get openSharedCalendar => 'Abrir calendario principal compartido'; @override String get manageCalendarSharing => @@ -3603,4 +3607,165 @@ class AppLocalizationsEs extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Añadir conexión de Google separada'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Este cambio en la repetición puede añadir o eliminar fechas.\n\nActual: $before\nPropuesta: $after\n\n¿Guardar esta repetición?'; + } + + @override + String get connectedAccounts => 'Cuentas conectadas'; + + @override + String get contactsIndependentAccounts => + 'Cuentas de contactos independientes'; + + @override + String get contactsNextcloudReuse => + 'Las sugerencias reutilizan las credenciales de esta cuenta mediante la integración de contactos de solo lectura.'; + + @override + String get contactsRetrySync => 'Reintentar sincronización de contactos'; + + @override + String get contactsPreferenceFailed => + 'No se pudo guardar la preferencia de la fuente de contactos.'; + + @override + String get contactsSuggestionsDisabled => + 'Sugerencias de contactos desactivadas'; + + @override + String get contactsSyncFailed => + 'No se pudieron sincronizar los contactos. Reinténtelo o vuelva a conectar si la autorización caducó.'; + + @override + String get registrationRecoveryDescription => + 'El registro guardado o su vínculo de credenciales no está disponible o fue rechazado. Sustituya explícitamente el registro de esta cuenta para recuperarla; se conservan los datos.'; + + @override + String get revalidateAccount => 'Revalidar conexión'; + + @override + String get openSharedPrimaryDescription => + 'Abre el calendario principal del propietario indicado. Los demás calendarios compartidos se descubren mediante la sincronización habitual.'; + + @override + String get showInBusyMax => 'Mostrar en BusyMax'; + + @override + String get showInGoogleCalendarList => + 'Mostrar en la lista de calendarios de Google'; + + @override + String get googleVisibilityHelp => + 'La selección de BusyMax es local. La visibilidad de Google cambia su lista en Google; los calendarios ocultos no se pueden mostrar en BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'El calendario principal de Google permanece en su lista.'; + + @override + String get accountsLoading => 'Cargando cuentas…'; + + @override + String get accountsLoadFailed => + 'No se pudieron cargar las cuentas. Vuelva a abrir Cuentas.'; + + @override + String get collectionsLoading => 'Cargando colecciones…'; + + @override + String get collectionsLoadFailed => + 'No se pudieron cargar las colecciones. Se conserva la caché; reintente la sincronización.'; + + @override + String get collectionsNotDiscovered => + 'El descubrimiento no ha terminado. Actualice o vuelva a conectar esta cuenta.'; + + @override + String get collectionsEmpty => 'No se encontraron colecciones.'; + + @override + String get accountReadyToSync => 'Listo para sincronizar'; + + @override + String get accountSyncProblem => 'La sincronización requiere atención'; + + @override + String get accountServiceUnreachable => 'No se pudo acceder al servicio'; + + @override + String get accountSyncDetails => 'Detalles de sincronización'; + + @override + String get accountReconnectStatus => 'Es necesario volver a conectar'; + + @override + String get accountRegistrationStatus => 'El registro requiere atención'; + + @override + String get collectionsUnavailable => 'Colecciones no disponibles'; + + @override + String get accountContactSuggestions => + 'Sugerir invitados de los contactos de esta cuenta'; + + @override + String get accountLoadingSignInSettings => + 'Cargando ajustes de inicio de sesión…'; + + @override + String get contactsNoAddressBooks => + 'No hay libretas de direcciones disponibles'; + + @override + String get contactsSourcesUnavailable => + 'No se pudieron cargar las libretas de direcciones'; + + @override + String get syncNoRecord => 'No se registró ninguna sincronización correcta'; + + @override + String get cachedSyncError => + 'Falló la sincronización; los datos en caché pueden estar desactualizados.'; + + @override + String get calendarRemotePending => + 'Cambio de visibilidad en Google pendiente'; + + @override + String get calendarRemoteFailed => + 'Falló el cambio de visibilidad en Google. Se muestra el último estado confirmado de Google. BusyMax sigue aplicando el cambio pendiente. Reinténtalo o descártalo.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return '¿Descartar los cambios pendientes de «$calendar»?\n\nEstos ajustes volverán a sus valores anteriores:\n$settings\n\nSe conserva tu selección de calendarios en BusyMax. No se enviarán cambios a Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Acceso no disponible; se muestra la caché. Reintente la sincronización o quite el calendario de su lista.'; + + @override + String get invalidCollectionColor => + 'Introduzca un color hexadecimal de seis u ocho dígitos que empiece por #.'; + + @override + String get invalidCollectionOrder => 'Introduzca un número entero.'; + + @override + String get invalidCollectionTimezone => + 'Introduzca un documento de zona horaria válido que contenga VTIMEZONE y TZID.'; + + @override + String get invalidCollectionValue => 'Corrija este valor antes de guardar.'; + + @override + String get accountAlreadyConnected => + 'Esta cuenta ya está conectada. Use su acción de reconexión.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax también intentará revocar la contraseña de aplicación de Nextcloud de esta cuenta. La eliminación local puede completarse aunque falle la revocación.'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index b451d802..376f1bd6 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -13,6 +13,10 @@ import 'app_localizations.dart'; class AppLocalizationsEt extends AppLocalizations { AppLocalizationsEt([String locale = 'et']) : super(locale); + @override + String get contactsAccountsUnavailable => + 'Kontaktikontosid ei õnnestunud laadida'; + @override String get launchAtLoginManagedExternally => 'BusyMax käivitati töölaua automaatkäivituse seadete kaudu. Eemalda sealt selle kirje, seejärel sulge BusyMax täielikult ja ava see lüliti kasutamiseks uuesti.'; @@ -2985,7 +2989,7 @@ class AppLocalizationsEt extends AppLocalizations { String get searchClearText => 'Tühjenda tekst'; @override - String get openSharedCalendar => 'Ava jagatud kalender'; + String get openSharedCalendar => 'Ava jagatud põhikalender'; @override String get manageCalendarSharing => 'Halda kalendri jagamist'; @@ -3570,4 +3574,161 @@ class AppLocalizationsEt extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Lisa eraldi Google’i ühendus'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Kordusmustri muutmine võib kuupäevi lisada või eemaldada.\n\nPraegune: $before\nPakutud: $after\n\nKas salvestada see muster?'; + } + + @override + String get connectedAccounts => 'Ühendatud kontod'; + + @override + String get contactsIndependentAccounts => 'Sõltumatud kontaktikontod'; + + @override + String get contactsNextcloudReuse => + 'Kontaktisoovitused kasutavad selle konto olemasolevaid pääsuandmeid kirjutuskaitstud kontaktide integratsiooni kaudu.'; + + @override + String get contactsRetrySync => 'Proovi kontaktide sünkroonimist uuesti'; + + @override + String get contactsPreferenceFailed => + 'Kontaktiallika eelistust ei saanud salvestada.'; + + @override + String get contactsSuggestionsDisabled => + 'Kontaktisoovitused on välja lülitatud'; + + @override + String get contactsSyncFailed => + 'Kontakte ei saanud sünkroonida. Proovi uuesti või ühenda konto uuesti, kui volitus on aegunud.'; + + @override + String get registrationRecoveryDescription => + 'Salvestatud registreering või selle pääsuandmete seos pole saadaval või on tagasi lükatud. Taastamiseks asenda selle konto registreering sõnaselgelt; andmed säilivad.'; + + @override + String get revalidateAccount => 'Kontrolli ühendust uuesti'; + + @override + String get openSharedPrimaryDescription => + 'Avab määratud omaniku põhikalendri. Muud jagatud kalendrid leitakse konto tavapärase sünkroonimise käigus.'; + + @override + String get showInBusyMax => 'Kuva BusyMaxis'; + + @override + String get showInGoogleCalendarList => 'Kuva Google’i kalendriloendis'; + + @override + String get googleVisibilityHelp => + 'BusyMaxi valik on kohalik. Google’i nähtavus muudab sinu loendit Google’is; peidetud kalendreid ei saa BusyMaxis kuvada.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Google’i põhikalender jääb loendisse.'; + + @override + String get accountsLoading => 'Kontode laadimine…'; + + @override + String get accountsLoadFailed => + 'Kontosid ei saanud laadida. Ava Kontod uuesti.'; + + @override + String get collectionsLoading => 'Kogumite laadimine…'; + + @override + String get collectionsLoadFailed => + 'Kogumeid ei saanud laadida. Vahemälu säilib; proovi sünkroonimist uuesti.'; + + @override + String get collectionsNotDiscovered => + 'Tuvastamine pole lõppenud. Värskenda või ühenda konto uuesti.'; + + @override + String get collectionsEmpty => 'Kogumeid ei leitud.'; + + @override + String get accountReadyToSync => 'Sünkroonimiseks valmis'; + + @override + String get accountSyncProblem => 'Sünkroonimine vajab tähelepanu'; + + @override + String get accountServiceUnreachable => 'Teenusega ei saanud ühendust'; + + @override + String get accountSyncDetails => 'Sünkroonimise üksikasjad'; + + @override + String get accountReconnectStatus => 'Ühenda konto uuesti'; + + @override + String get accountRegistrationStatus => + 'Rakenduse registreering vajab tähelepanu'; + + @override + String get collectionsUnavailable => 'Kogud pole saadaval'; + + @override + String get accountContactSuggestions => + 'Soovita külalisi selle konto kontaktidest'; + + @override + String get accountLoadingSignInSettings => 'Sisselogimisseadete laadimine…'; + + @override + String get contactsNoAddressBooks => 'Aadressiraamatud pole saadaval'; + + @override + String get contactsSourcesUnavailable => + 'Aadressiraamatuid ei saanud laadida'; + + @override + String get syncNoRecord => 'Eduka sünkroonimise aega pole salvestatud'; + + @override + String get cachedSyncError => + 'Sünkroonimine nurjus; vahemälu andmed võivad olla aegunud.'; + + @override + String get calendarRemotePending => 'Google’i nähtavuse muudatus on ootel'; + + @override + String get calendarRemoteFailed => + 'Google’i nähtavuse muutmine ebaõnnestus. Kuvatakse Google’i viimane kinnitatud olek. BusyMax järgib endiselt ootel muudatust. Proovi uuesti või loobu muudatusest.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Kas hüljata kalendri „$calendar“ ootel muudatused?\n\nNende seadete eelmised väärtused taastatakse:\n$settings\n\nSinu BusyMaxi kalendrivalik säilib. Google’ile ei saadeta muudatusi.'; + } + + @override + String get sharedCalendarUnavailable => + 'Juurdepääs puudub; kuvatakse vahemälu andmed. Proovi sünkroonimist uuesti või eemalda kalender loendist.'; + + @override + String get invalidCollectionColor => + 'Sisesta kuue- või kaheksakohaline kuueteistkümnendvärv, alustades märgiga #.'; + + @override + String get invalidCollectionOrder => 'Sisesta täisarv.'; + + @override + String get invalidCollectionTimezone => + 'Sisesta kehtiv kalendri ajavööndi dokument, mis sisaldab VTIMEZONE ja TZID väärtusi.'; + + @override + String get invalidCollectionValue => 'Paranda see väärtus enne salvestamist.'; + + @override + String get accountAlreadyConnected => + 'See konto on juba ühendatud. Kasuta selle uuesti ühendamise toimingut.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax proovib tühistada ka selle konto Nextcloudi rakenduse parooli. Kohalik eemaldamine võib õnnestuda ka siis, kui tühistamine nurjub.'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 02e2c5ca..406e5947 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String get contactsAccountsUnavailable => 'حساب‌های مخاطبین بارگیری نشدند'; + @override String get launchAtLoginManagedExternally => 'BusyMax از تنظیمات راه‌اندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.'; @@ -3041,7 +3044,7 @@ class AppLocalizationsFa extends AppLocalizations { String get searchClearText => 'پاک کردن متن'; @override - String get openSharedCalendar => 'باز کردن تقویم اشتراکی'; + String get openSharedCalendar => 'باز کردن تقویم اصلی اشتراکی'; @override String get manageCalendarSharing => 'مدیریت اشتراک‌گذاری تقویم'; @@ -3625,4 +3628,156 @@ class AppLocalizationsFa extends AppLocalizations { @override String get addSeparateGoogleAccount => 'افزودن اتصال Google جداگانه'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'این تغییر در الگوی تکرار ممکن است تاریخ‌هایی را اضافه یا حذف کند.\n\nفعلی: ⁨$before⁩\nپیشنهادی: ⁨$after⁩\n\nاین الگو ذخیره شود؟'; + } + + @override + String get connectedAccounts => 'حساب‌های متصل'; + + @override + String get contactsIndependentAccounts => 'حساب‌های مستقل مخاطبان'; + + @override + String get contactsNextcloudReuse => + 'پیشنهادهای مخاطب از اطلاعات ورود فعلی این حساب از طریق یکپارچه‌سازی فقط‌خواندنی مخاطبان استفاده می‌کنند.'; + + @override + String get contactsRetrySync => 'تلاش دوباره برای همگام‌سازی مخاطبان'; + + @override + String get contactsPreferenceFailed => 'ذخیره تنظیم منبع مخاطبان ممکن نشد.'; + + @override + String get contactsSuggestionsDisabled => 'پیشنهادهای مخاطب غیرفعال است'; + + @override + String get contactsSyncFailed => + 'همگام‌سازی مخاطبان انجام نشد. دوباره تلاش کنید یا اگر مجوز منقضی شده حساب را دوباره متصل کنید.'; + + @override + String get registrationRecoveryDescription => + 'ثبت ذخیره‌شده یا پیوند اطلاعات ورود آن در دسترس نیست یا رد شده است. برای بازیابی، ثبت این حساب را به‌صراحت جایگزین کنید؛ داده‌های فعلی حفظ می‌شوند.'; + + @override + String get revalidateAccount => 'بررسی دوباره اتصال'; + + @override + String get openSharedPrimaryDescription => + 'تقویم اصلی مالک مشخص‌شده را باز می‌کند. تقویم‌های اشتراکی دیگر از طریق همگام‌سازی عادی حساب پیدا می‌شوند.'; + + @override + String get showInBusyMax => 'نمایش در BusyMax'; + + @override + String get showInGoogleCalendarList => 'نمایش در فهرست تقویم Google'; + + @override + String get googleVisibilityHelp => + 'انتخاب BusyMax محلی است. نمایانی Google فهرست شما در Google را تغییر می‌دهد؛ تقویم‌های پنهان در BusyMax نمایش داده نمی‌شوند.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'تقویم اصلی Google در فهرست باقی می‌ماند.'; + + @override + String get accountsLoading => 'در حال بارگذاری حساب‌ها…'; + + @override + String get accountsLoadFailed => + 'حساب‌ها بارگذاری نشدند. بخش حساب‌ها را دوباره باز کنید.'; + + @override + String get collectionsLoading => 'در حال بارگذاری مجموعه‌ها…'; + + @override + String get collectionsLoadFailed => + 'مجموعه‌ها بارگذاری نشدند. داده‌های ذخیره‌شده حفظ می‌شوند؛ همگام‌سازی را دوباره امتحان کنید.'; + + @override + String get collectionsNotDiscovered => + 'کشف کامل نشده است. این حساب را تازه‌سازی یا دوباره متصل کنید.'; + + @override + String get collectionsEmpty => 'مجموعه‌ای پیدا نشد.'; + + @override + String get accountReadyToSync => 'آمادهٔ همگام‌سازی'; + + @override + String get accountSyncProblem => 'همگام‌سازی نیاز به رسیدگی دارد'; + + @override + String get accountServiceUnreachable => 'دسترسی به سرویس ممکن نشد'; + + @override + String get accountSyncDetails => 'جزئیات همگام‌سازی'; + + @override + String get accountReconnectStatus => 'اتصال مجدد لازم است'; + + @override + String get accountRegistrationStatus => 'ثبت برنامه نیاز به رسیدگی دارد'; + + @override + String get collectionsUnavailable => 'مجموعه‌ها در دسترس نیستند'; + + @override + String get accountContactSuggestions => 'پیشنهاد مهمان از مخاطبان این حساب'; + + @override + String get accountLoadingSignInSettings => 'در حال بارگیری تنظیمات ورود…'; + + @override + String get contactsNoAddressBooks => 'دفترچهٔ نشانی در دسترس نیست'; + + @override + String get contactsSourcesUnavailable => 'بارگیری دفترچه‌های نشانی ممکن نشد'; + + @override + String get syncNoRecord => 'زمان همگام‌سازی موفق ثبت نشده است'; + + @override + String get cachedSyncError => + 'همگام‌سازی ناموفق بود؛ داده‌های ذخیره‌شده ممکن است قدیمی باشند.'; + + @override + String get calendarRemotePending => 'تغییر نمایانی Google در انتظار است'; + + @override + String get calendarRemoteFailed => + 'تغییر نمایش در Google ناموفق بود. آخرین وضعیت تأییدشدهٔ Google نمایش داده می‌شود. BusyMax همچنان از تغییر در انتظار پیروی می‌کند. دوباره تلاش کنید یا تغییر را کنار بگذارید.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'تغییرات در انتظار برای «⁨$calendar⁩» کنار گذاشته شوند؟\n\nاین تنظیمات به مقادیر قبلی بازمی‌گردند:\n⁨$settings⁩\n\nانتخاب تقویم شما در BusyMax حفظ می‌شود. هیچ تغییری به Google ارسال نخواهد شد.'; + } + + @override + String get sharedCalendarUnavailable => + 'دسترسی موجود نیست؛ داده‌های ذخیره‌شده نمایش داده می‌شوند. همگام‌سازی را دوباره امتحان کنید یا تقویم را از فهرست حذف کنید.'; + + @override + String get invalidCollectionColor => + 'یک رنگ شانزده‌شانزدهی شش یا هشت رقمی با # در ابتدا وارد کنید.'; + + @override + String get invalidCollectionOrder => 'یک عدد صحیح وارد کنید.'; + + @override + String get invalidCollectionTimezone => + 'یک سند منطقه زمانی معتبر شامل VTIMEZONE و TZID وارد کنید.'; + + @override + String get invalidCollectionValue => 'این مقدار را پیش از ذخیره اصلاح کنید.'; + + @override + String get accountAlreadyConnected => + 'این حساب از قبل متصل است. از گزینه اتصال دوباره آن استفاده کنید.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax تلاش می‌کند گذرواژه برنامه Nextcloud این حساب را نیز لغو کند. حذف محلی حتی در صورت ناموفق بودن لغو می‌تواند انجام شود.'; } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index c1507c84..4da7fbc5 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsFi extends AppLocalizations { AppLocalizationsFi([String locale = 'fi']) : super(locale); + @override + String get contactsAccountsUnavailable => 'Yhteystietotilejä ei voitu ladata'; + @override String get launchAtLoginManagedExternally => 'BusyMax käynnistettiin työpöydän automaattisen käynnistyksen asetuksista. Poista sen merkintä sieltä, lopeta BusyMax ja avaa se uudelleen, jotta voit käyttää tätä kytkintä.'; @@ -3035,7 +3038,7 @@ class AppLocalizationsFi extends AppLocalizations { String get searchClearText => 'Tyhjennä teksti'; @override - String get openSharedCalendar => 'Avaa jaettu kalenteri'; + String get openSharedCalendar => 'Avaa jaettu ensisijainen kalenteri'; @override String get manageCalendarSharing => 'Hallitse kalenterin jakamista'; @@ -3621,4 +3624,162 @@ class AppLocalizationsFi extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Lisää erillinen Google-yhteys'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Toistumissäännön muuttaminen voi lisätä tai poistaa päivämääriä.\n\nNykyinen: $before\nEhdotettu: $after\n\nTallennetaanko tämä sääntö?'; + } + + @override + String get connectedAccounts => 'Yhdistetyt tilit'; + + @override + String get contactsIndependentAccounts => 'Erilliset yhteystietotilit'; + + @override + String get contactsNextcloudReuse => + 'Yhteystietoehdotukset käyttävät tämän tilin nykyisiä tunnistetietoja vain luku -integraation kautta.'; + + @override + String get contactsRetrySync => + 'Yritä yhteystietojen synkronointia uudelleen'; + + @override + String get contactsPreferenceFailed => + 'Yhteystietolähteen asetusta ei voitu tallentaa.'; + + @override + String get contactsSuggestionsDisabled => + 'Yhteystietoehdotukset poistettu käytöstä'; + + @override + String get contactsSyncFailed => + 'Yhteystietoja ei voitu synkronoida. Yritä uudelleen tai yhdistä tili uudelleen, jos valtuutus on vanhentunut.'; + + @override + String get registrationRecoveryDescription => + 'Tallennettu rekisteröinti tai sen tunnistetietosidos puuttuu tai on hylätty. Korvaa tämän tilin rekisteröinti erikseen palautusta varten; tiedot säilytetään.'; + + @override + String get revalidateAccount => 'Tarkista yhteys uudelleen'; + + @override + String get openSharedPrimaryDescription => + 'Avaa annetun omistajan ensisijaisen kalenterin. Muut jaetut kalenterit löytyvät tavallisen tilisynkronoinnin kautta.'; + + @override + String get showInBusyMax => 'Näytä BusyMaxissa'; + + @override + String get showInGoogleCalendarList => 'Näytä Googlen kalenteriluettelossa'; + + @override + String get googleVisibilityHelp => + 'BusyMaxin valinta on paikallinen. Google-näkyvyys muuttaa Googlen luetteloa; piilotettuja kalentereita ei voi näyttää BusyMaxissa.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Googlen ensisijainen kalenteri pysyy luettelossa.'; + + @override + String get accountsLoading => 'Ladataan tilejä…'; + + @override + String get accountsLoadFailed => + 'Tilejä ei voitu ladata. Avaa Tilit uudelleen.'; + + @override + String get collectionsLoading => 'Ladataan kokoelmia…'; + + @override + String get collectionsLoadFailed => + 'Kokoelmia ei voitu ladata. Välimuisti säilytetään; yritä synkronointia uudelleen.'; + + @override + String get collectionsNotDiscovered => + 'Haku ei ole vielä valmis. Päivitä tai yhdistä tämä tili uudelleen.'; + + @override + String get collectionsEmpty => 'Kokoelmia ei löytynyt.'; + + @override + String get accountReadyToSync => 'Valmis synkronointiin'; + + @override + String get accountSyncProblem => 'Synkronointi vaatii huomiota'; + + @override + String get accountServiceUnreachable => 'Palveluun ei saatu yhteyttä'; + + @override + String get accountSyncDetails => 'Synkronoinnin tiedot'; + + @override + String get accountReconnectStatus => 'Yhdistä tili uudelleen'; + + @override + String get accountRegistrationStatus => + 'Sovellusrekisteröinti vaatii huomiota'; + + @override + String get collectionsUnavailable => 'Kokoelmat eivät ole käytettävissä'; + + @override + String get accountContactSuggestions => + 'Ehdota vieraita tämän tilin yhteystiedoista'; + + @override + String get accountLoadingSignInSettings => 'Ladataan kirjautumisasetuksia…'; + + @override + String get contactsNoAddressBooks => 'Osoitekirjoja ei ole käytettävissä'; + + @override + String get contactsSourcesUnavailable => 'Osoitekirjoja ei voitu ladata'; + + @override + String get syncNoRecord => + 'Onnistuneen synkronoinnin aikaa ei ole tallennettu'; + + @override + String get cachedSyncError => + 'Synkronointi epäonnistui; välimuistin tiedot voivat olla vanhentuneita.'; + + @override + String get calendarRemotePending => 'Googlen näkyvyysmuutos odottaa'; + + @override + String get calendarRemoteFailed => + 'Näkyvyyden muuttaminen Googlessa epäonnistui. Googlen viimeisin vahvistettu tila näytetään. BusyMax noudattaa yhä odottavaa muutosta. Yritä uudelleen tai hylkää muutos.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Hylätäänkö kalenterin ”$calendar” odottavat muutokset?\n\nNämä asetukset palautetaan aiempiin arvoihinsa:\n$settings\n\nBusyMaxin kalenterivalintasi säilyy. Googlelle ei lähetetä muutoksia.'; + } + + @override + String get sharedCalendarUnavailable => + 'Ei käyttöoikeutta; näytetään välimuistin tiedot. Yritä synkronointia uudelleen tai poista kalenteri luettelosta.'; + + @override + String get invalidCollectionColor => + 'Anna kuusi- tai kahdeksannumeroinen heksaväri, joka alkaa #-merkillä.'; + + @override + String get invalidCollectionOrder => 'Anna kokonaisluku.'; + + @override + String get invalidCollectionTimezone => + 'Anna kelvollinen kalenterin aikavyöhykeasiakirja, joka sisältää VTIMEZONE- ja TZID-tiedot.'; + + @override + String get invalidCollectionValue => 'Korjaa tämä arvo ennen tallentamista.'; + + @override + String get accountAlreadyConnected => + 'Tämä tili on jo yhdistetty. Käytä sen uudelleenyhdistämistoimintoa.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax yrittää myös perua tämän tilin Nextcloud-sovellussalasanan. Paikallinen poisto voi onnistua, vaikka peruminen epäonnistuu.'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 8cf7c074..12c5e608 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -13,6 +13,10 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get contactsAccountsUnavailable => + 'Impossible de charger les comptes de contacts'; + @override String get launchAtLoginManagedExternally => 'BusyMax a été lancé par les paramètres de démarrage de votre bureau. Supprimez son entrée à cet endroit, puis quittez et rouvrez BusyMax pour utiliser cet interrupteur.'; @@ -3011,7 +3015,7 @@ class AppLocalizationsFr extends AppLocalizations { String get searchClearText => 'Effacer le texte'; @override - String get openSharedCalendar => 'Ouvrir un calendrier partagé'; + String get openSharedCalendar => 'Ouvrir un calendrier principal partagé'; @override String get manageCalendarSharing => 'Gérer le partage de l’agenda'; @@ -3609,4 +3613,167 @@ class AppLocalizationsFr extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Ajouter une connexion Google distincte'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Cette modification du schéma de répétition peut ajouter ou supprimer des dates.\n\nActuel : $before\nProposé : $after\n\nEnregistrer ce schéma ?'; + } + + @override + String get connectedAccounts => 'Comptes connectés'; + + @override + String get contactsIndependentAccounts => 'Comptes de contacts indépendants'; + + @override + String get contactsNextcloudReuse => + 'Les suggestions réutilisent les identifiants de ce compte via l’intégration de contacts en lecture seule.'; + + @override + String get contactsRetrySync => 'Réessayer la synchronisation des contacts'; + + @override + String get contactsPreferenceFailed => + 'Impossible d’enregistrer la préférence de la source de contacts.'; + + @override + String get contactsSuggestionsDisabled => + 'Suggestions de contacts désactivées'; + + @override + String get contactsSyncFailed => + 'Les contacts n’ont pas pu être synchronisés. Réessayez ou reconnectez le compte si l’autorisation a expiré.'; + + @override + String get registrationRecoveryDescription => + 'L’inscription enregistrée ou son association aux identifiants est indisponible ou refusée. Remplacez explicitement l’inscription de ce compte pour le récupérer ; les données sont conservées.'; + + @override + String get revalidateAccount => 'Revérifier la connexion'; + + @override + String get openSharedPrimaryDescription => + 'Ouvre le calendrier principal du propriétaire indiqué. Les autres calendriers partagés sont détectés lors de la synchronisation habituelle.'; + + @override + String get showInBusyMax => 'Afficher dans BusyMax'; + + @override + String get showInGoogleCalendarList => + 'Afficher dans la liste de calendriers Google'; + + @override + String get googleVisibilityHelp => + 'La sélection BusyMax est locale. La visibilité Google modifie votre liste chez Google ; les calendriers masqués ne peuvent pas être affichés dans BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Le calendrier principal Google reste dans sa liste.'; + + @override + String get accountsLoading => 'Chargement des comptes…'; + + @override + String get accountsLoadFailed => + 'Impossible de charger les comptes. Rouvrez Comptes.'; + + @override + String get collectionsLoading => 'Chargement des collections…'; + + @override + String get collectionsLoadFailed => + 'Impossible de charger les collections. Le cache est conservé ; réessayez la synchronisation.'; + + @override + String get collectionsNotDiscovered => + 'La découverte n’est pas terminée. Actualisez ou reconnectez ce compte.'; + + @override + String get collectionsEmpty => 'Aucune collection trouvée.'; + + @override + String get accountReadyToSync => 'Prêt à synchroniser'; + + @override + String get accountSyncProblem => + 'La synchronisation nécessite une intervention'; + + @override + String get accountServiceUnreachable => 'Impossible de joindre le service'; + + @override + String get accountSyncDetails => 'Détails de synchronisation'; + + @override + String get accountReconnectStatus => 'Reconnexion nécessaire'; + + @override + String get accountRegistrationStatus => + 'L’enregistrement nécessite une intervention'; + + @override + String get collectionsUnavailable => 'Collections indisponibles'; + + @override + String get accountContactSuggestions => + 'Suggérer des invités à partir des contacts de ce compte'; + + @override + String get accountLoadingSignInSettings => + 'Chargement des paramètres de connexion…'; + + @override + String get contactsNoAddressBooks => 'Aucun carnet d’adresses disponible'; + + @override + String get contactsSourcesUnavailable => + 'Impossible de charger les carnets d’adresses'; + + @override + String get syncNoRecord => + 'Aucune date de synchronisation réussie enregistrée'; + + @override + String get cachedSyncError => + 'Échec de synchronisation ; les données en cache peuvent être anciennes.'; + + @override + String get calendarRemotePending => + 'Modification de visibilité Google en attente'; + + @override + String get calendarRemoteFailed => + 'La modification de visibilité dans Google a échoué. Le dernier état confirmé de Google est affiché. BusyMax suit toujours la modification en attente. Réessayez ou abandonnez cette modification.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Abandonner les modifications en attente pour « $calendar » ?\n\nCes paramètres retrouveront leurs valeurs précédentes :\n$settings\n\nVotre sélection de calendriers dans BusyMax est conservée. Aucune modification ne sera envoyée à Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Accès indisponible ; affichage du cache. Réessayez la synchronisation ou retirez le calendrier de votre liste.'; + + @override + String get invalidCollectionColor => + 'Saisissez une couleur hexadécimale de six ou huit chiffres commençant par #.'; + + @override + String get invalidCollectionOrder => 'Saisissez un nombre entier.'; + + @override + String get invalidCollectionTimezone => + 'Saisissez un document de fuseau horaire valide contenant VTIMEZONE et TZID.'; + + @override + String get invalidCollectionValue => + 'Corrigez cette valeur avant d’enregistrer.'; + + @override + String get accountAlreadyConnected => + 'Ce compte est déjà connecté. Utilisez son action de reconnexion.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax tentera aussi de révoquer le mot de passe d’application Nextcloud de ce compte. La suppression locale peut réussir même si cette révocation échoue.'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 5f31072d..cf700463 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String get contactsAccountsUnavailable => 'संपर्क खाते लोड नहीं किए जा सके'; + @override String get launchAtLoginManagedExternally => 'BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।'; @@ -3006,7 +3009,7 @@ class AppLocalizationsHi extends AppLocalizations { String get searchClearText => 'पाठ साफ़ करें'; @override - String get openSharedCalendar => 'साझा कैलेंडर खोलें'; + String get openSharedCalendar => 'साझा मुख्य कैलेंडर खोलें'; @override String get manageCalendarSharing => 'कैलेंडर साझाकरण प्रबंधित करें'; @@ -3591,4 +3594,156 @@ class AppLocalizationsHi extends AppLocalizations { @override String get addSeparateGoogleAccount => 'अलग Google कनेक्शन जोड़ें'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'दोहराव का यह बदलाव तारीखें जोड़ या हटा सकता है।\n\nवर्तमान: $before\nप्रस्तावित: $after\n\nयह पैटर्न सहेजें?'; + } + + @override + String get connectedAccounts => 'जुड़े हुए खाते'; + + @override + String get contactsIndependentAccounts => 'स्वतंत्र संपर्क खाते'; + + @override + String get contactsNextcloudReuse => + 'संपर्क सुझाव केवल पढ़ने वाले संपर्क एकीकरण के माध्यम से इस खाते के मौजूदा क्रेडेंशियल का दोबारा उपयोग करते हैं।'; + + @override + String get contactsRetrySync => 'संपर्क सिंक फिर आज़माएँ'; + + @override + String get contactsPreferenceFailed => + 'संपर्क स्रोत की प्राथमिकता सहेजी नहीं जा सकी।'; + + @override + String get contactsSuggestionsDisabled => 'संपर्क सुझाव बंद हैं'; + + @override + String get contactsSyncFailed => + 'संपर्क सिंक नहीं हो सके। फिर आज़माएँ, या अनुमति समाप्त होने पर खाता फिर जोड़ें।'; + + @override + String get registrationRecoveryDescription => + 'सहेजा गया पंजीकरण या उसका क्रेडेंशियल संबंध उपलब्ध नहीं है या अस्वीकार कर दिया गया है। बहाली के लिए इस खाते का पंजीकरण स्पष्ट रूप से बदलें; मौजूदा डेटा सुरक्षित रहेगा।'; + + @override + String get revalidateAccount => 'कनेक्शन फिर सत्यापित करें'; + + @override + String get openSharedPrimaryDescription => + 'निर्दिष्ट मालिक का मुख्य कैलेंडर खोलता है। अन्य साझा कैलेंडर सामान्य खाता सिंक में खोजे जाते हैं।'; + + @override + String get showInBusyMax => 'BusyMax में दिखाएँ'; + + @override + String get showInGoogleCalendarList => 'Google की कैलेंडर सूची में दिखाएँ'; + + @override + String get googleVisibilityHelp => + 'BusyMax का चयन स्थानीय है। Google दृश्यता Google पर आपकी सूची बदलती है; छिपे कैलेंडर BusyMax में नहीं दिखाए जा सकते।'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Google का मुख्य कैलेंडर उसकी सूची में रहता है।'; + + @override + String get accountsLoading => 'खाते लोड हो रहे हैं…'; + + @override + String get accountsLoadFailed => 'खाते लोड नहीं हो सके। खाते फिर खोलें।'; + + @override + String get collectionsLoading => 'संग्रह लोड हो रहे हैं…'; + + @override + String get collectionsLoadFailed => + 'संग्रह लोड नहीं हो सके। कैश डेटा सुरक्षित है; सिंक फिर आज़माएँ।'; + + @override + String get collectionsNotDiscovered => + 'खोज पूरी नहीं हुई है। इस खाते को रीफ़्रेश करें या फिर जोड़ें।'; + + @override + String get collectionsEmpty => 'कोई संग्रह नहीं मिला।'; + + @override + String get accountReadyToSync => 'सिंक करने के लिए तैयार'; + + @override + String get accountSyncProblem => 'सिंक पर ध्यान देना आवश्यक है'; + + @override + String get accountServiceUnreachable => 'सेवा से संपर्क नहीं हो सका'; + + @override + String get accountSyncDetails => 'सिंक का विवरण'; + + @override + String get accountReconnectStatus => 'दोबारा कनेक्ट करना आवश्यक है'; + + @override + String get accountRegistrationStatus => 'ऐप पंजीकरण पर ध्यान देना आवश्यक है'; + + @override + String get collectionsUnavailable => 'संग्रह उपलब्ध नहीं हैं'; + + @override + String get accountContactSuggestions => + 'इस खाते के संपर्कों से मेहमान सुझाएँ'; + + @override + String get accountLoadingSignInSettings => 'साइन-इन सेटिंग लोड हो रही हैं…'; + + @override + String get contactsNoAddressBooks => 'कोई पता पुस्तिका उपलब्ध नहीं है'; + + @override + String get contactsSourcesUnavailable => 'पता पुस्तिकाएँ लोड नहीं हो सकीं'; + + @override + String get syncNoRecord => 'सफल सिंक का समय दर्ज नहीं है'; + + @override + String get cachedSyncError => 'सिंक विफल हुआ; कैश डेटा पुराना हो सकता है।'; + + @override + String get calendarRemotePending => 'Google दृश्यता बदलाव लंबित है'; + + @override + String get calendarRemoteFailed => + 'Google की दृश्यता बदलना विफल रहा। Google की पिछली पुष्ट स्थिति दिखाई गई है। BusyMax अभी भी लंबित बदलाव का पालन करता है। फिर से कोशिश करें या बदलाव छोड़ दें।'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return '“$calendar” के लंबित बदलाव छोड़ें?\n\nये सेटिंग अपने पिछले मानों पर लौट जाएँगी:\n$settings\n\nBusyMax में आपका कैलेंडर चयन बना रहेगा। Google को कोई बदलाव नहीं भेजा जाएगा।'; + } + + @override + String get sharedCalendarUnavailable => + 'पहुँच उपलब्ध नहीं; कैश डेटा दिखाया जा रहा है। सिंक फिर आज़माएँ या कैलेंडर को अपनी सूची से हटाएँ।'; + + @override + String get invalidCollectionColor => + '# से शुरू होने वाला छह या आठ अंकों का हेक्स रंग दर्ज करें।'; + + @override + String get invalidCollectionOrder => 'एक पूर्णांक दर्ज करें।'; + + @override + String get invalidCollectionTimezone => + 'VTIMEZONE और TZID वाला मान्य कैलेंडर समय क्षेत्र दस्तावेज़ दर्ज करें।'; + + @override + String get invalidCollectionValue => 'सहेजने से पहले यह मान सुधारें।'; + + @override + String get accountAlreadyConnected => + 'यह खाता पहले से जुड़ा है। इसके फिर से जोड़ने वाले विकल्प का उपयोग करें।'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax इस खाते का Nextcloud ऐप पासवर्ड निरस्त करने का भी प्रयास करेगा। निरस्तीकरण विफल होने पर भी स्थानीय हटाना सफल हो सकता है।'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index f6761f95..131b70c2 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -13,6 +13,10 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get contactsAccountsUnavailable => + 'Impossibile caricare gli account dei contatti'; + @override String get launchAtLoginManagedExternally => 'BusyMax è stato avviato dalle impostazioni di avvio del desktop. Rimuovi la sua voce da lì, poi esci da BusyMax e riaprilo per usare questo interruttore.'; @@ -3026,7 +3030,7 @@ class AppLocalizationsIt extends AppLocalizations { String get searchClearText => 'Cancella testo'; @override - String get openSharedCalendar => 'Apri calendario condiviso'; + String get openSharedCalendar => 'Apri calendario principale condiviso'; @override String get manageCalendarSharing => 'Gestisci condivisione del calendario'; @@ -3617,4 +3621,164 @@ class AppLocalizationsIt extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Aggiungi connessione Google separata'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Questa modifica della ricorrenza può aggiungere o rimuovere date.\n\nAttuale: $before\nProposta: $after\n\nSalvare questa ricorrenza?'; + } + + @override + String get connectedAccounts => 'Account collegati'; + + @override + String get contactsIndependentAccounts => 'Account di contatti indipendenti'; + + @override + String get contactsNextcloudReuse => + 'I suggerimenti riutilizzano le credenziali di questo account tramite l’integrazione dei contatti in sola lettura.'; + + @override + String get contactsRetrySync => 'Riprova sincronizzazione contatti'; + + @override + String get contactsPreferenceFailed => + 'Impossibile salvare la preferenza della fonte dei contatti.'; + + @override + String get contactsSuggestionsDisabled => + 'Suggerimenti dei contatti disattivati'; + + @override + String get contactsSyncFailed => + 'Impossibile sincronizzare i contatti. Riprova oppure ricollega l’account se l’autorizzazione è scaduta.'; + + @override + String get registrationRecoveryDescription => + 'La registrazione salvata o il collegamento alle credenziali non è disponibile o è stato rifiutato. Sostituisci esplicitamente la registrazione di questo account per recuperarlo; i dati vengono conservati.'; + + @override + String get revalidateAccount => 'Verifica di nuovo la connessione'; + + @override + String get openSharedPrimaryDescription => + 'Apre il calendario principale del proprietario specificato. Gli altri calendari condivisi vengono rilevati tramite la normale sincronizzazione.'; + + @override + String get showInBusyMax => 'Mostra in BusyMax'; + + @override + String get showInGoogleCalendarList => + 'Mostra nell’elenco dei calendari Google'; + + @override + String get googleVisibilityHelp => + 'La selezione di BusyMax è locale. La visibilità Google modifica il tuo elenco su Google; i calendari nascosti non possono essere visualizzati in BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Il calendario principale Google rimane nel suo elenco.'; + + @override + String get accountsLoading => 'Caricamento account…'; + + @override + String get accountsLoadFailed => + 'Impossibile caricare gli account. Riapri Account.'; + + @override + String get collectionsLoading => 'Caricamento raccolte…'; + + @override + String get collectionsLoadFailed => + 'Impossibile caricare le raccolte. I dati in cache sono conservati; riprova la sincronizzazione.'; + + @override + String get collectionsNotDiscovered => + 'Il rilevamento non è completo. Aggiorna o ricollega questo account.'; + + @override + String get collectionsEmpty => 'Nessuna raccolta trovata.'; + + @override + String get accountReadyToSync => 'Pronto per la sincronizzazione'; + + @override + String get accountSyncProblem => 'La sincronizzazione richiede attenzione'; + + @override + String get accountServiceUnreachable => 'Impossibile raggiungere il servizio'; + + @override + String get accountSyncDetails => 'Dettagli di sincronizzazione'; + + @override + String get accountReconnectStatus => 'È necessario riconnettere l’account'; + + @override + String get accountRegistrationStatus => + 'La registrazione richiede attenzione'; + + @override + String get collectionsUnavailable => 'Raccolte non disponibili'; + + @override + String get accountContactSuggestions => + 'Suggerisci invitati dai contatti di questo account'; + + @override + String get accountLoadingSignInSettings => + 'Caricamento delle impostazioni di accesso…'; + + @override + String get contactsNoAddressBooks => 'Nessuna rubrica disponibile'; + + @override + String get contactsSourcesUnavailable => 'Impossibile caricare le rubriche'; + + @override + String get syncNoRecord => + 'Nessuna data di sincronizzazione riuscita registrata'; + + @override + String get cachedSyncError => + 'Sincronizzazione non riuscita; i dati in cache potrebbero essere obsoleti.'; + + @override + String get calendarRemotePending => 'Modifica visibilità Google in attesa'; + + @override + String get calendarRemoteFailed => + 'La modifica della visibilità su Google non è riuscita. Viene mostrato l’ultimo stato confermato da Google. BusyMax continua a seguire la modifica in sospeso. Riprova o scarta la modifica.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Scartare le modifiche in attesa per «$calendar»?\n\nQueste impostazioni torneranno ai valori precedenti:\n$settings\n\nLa selezione dei calendari in BusyMax viene mantenuta. Non verranno inviate modifiche a Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Accesso non disponibile; vengono mostrati i dati in cache. Riprova o rimuovi il calendario dal tuo elenco.'; + + @override + String get invalidCollectionColor => + 'Inserisci un colore esadecimale di sei o otto cifre che inizi con #.'; + + @override + String get invalidCollectionOrder => 'Inserisci un numero intero.'; + + @override + String get invalidCollectionTimezone => + 'Inserisci un documento di fuso orario valido contenente VTIMEZONE e TZID.'; + + @override + String get invalidCollectionValue => + 'Correggi questo valore prima di salvare.'; + + @override + String get accountAlreadyConnected => + 'Questo account è già collegato. Usa la sua azione di riconnessione.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax tenterà anche di revocare la password per app Nextcloud di questo account. La rimozione locale può riuscire anche se la revoca non riesce.'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index cee642ae..e22a18b4 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get contactsAccountsUnavailable => '連絡先アカウントを読み込めませんでした'; + @override String get launchAtLoginManagedExternally => 'BusyMax はデスクトップの自動起動設定によって起動されました。このスイッチを使うには、そこで BusyMax の項目を削除してから、BusyMax を終了して開き直してください。'; @@ -2943,7 +2946,7 @@ class AppLocalizationsJa extends AppLocalizations { String get searchClearText => 'テキストを消去'; @override - String get openSharedCalendar => '共有カレンダーを開く'; + String get openSharedCalendar => '共有メインカレンダーを開く'; @override String get manageCalendarSharing => 'カレンダーの共有を管理'; @@ -3507,4 +3510,152 @@ class AppLocalizationsJa extends AppLocalizations { @override String get addSeparateGoogleAccount => '別の Google 接続を追加'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return '繰り返しパターンの変更により、日付が追加または削除される場合があります。\n\n現在:$before\n変更案:$after\n\nこのパターンを保存しますか?'; + } + + @override + String get connectedAccounts => '接続済みアカウント'; + + @override + String get contactsIndependentAccounts => '独立した連絡先アカウント'; + + @override + String get contactsNextcloudReuse => + '連絡先候補は、読み取り専用の連絡先連携を通じて、このアカウントの既存の認証情報を再利用します。'; + + @override + String get contactsRetrySync => '連絡先の同期を再試行'; + + @override + String get contactsPreferenceFailed => '連絡先ソースの設定を保存できませんでした。'; + + @override + String get contactsSuggestionsDisabled => '連絡先候補は無効です'; + + @override + String get contactsSyncFailed => + '連絡先を同期できませんでした。再試行するか、認可が期限切れの場合はアカウントを再接続してください。'; + + @override + String get registrationRecoveryDescription => + '保存済みのアプリ登録または認証情報との関連付けが利用できないか、拒否されています。復旧するには、このアカウントの登録を明示的に置き換えてください。既存のデータは保持されます。'; + + @override + String get revalidateAccount => '接続を再検証'; + + @override + String get openSharedPrimaryDescription => + '指定した所有者のメインカレンダーを開きます。他の共有カレンダーは通常のアカウント同期で検出されます。'; + + @override + String get showInBusyMax => 'BusyMax に表示'; + + @override + String get showInGoogleCalendarList => 'Google のカレンダーリストに表示'; + + @override + String get googleVisibilityHelp => + 'BusyMax の選択はローカル設定です。Google の表示設定は Google 側のリストを変更します。非表示のカレンダーは BusyMax に表示できません。'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Google のメインカレンダーはリストに残ります。'; + + @override + String get accountsLoading => 'アカウントを読み込み中…'; + + @override + String get accountsLoadFailed => 'アカウントを読み込めませんでした。「アカウント」を開き直してください。'; + + @override + String get collectionsLoading => 'コレクションを読み込み中…'; + + @override + String get collectionsLoadFailed => + 'コレクションを読み込めませんでした。キャッシュは保持されています。同期を再試行してください。'; + + @override + String get collectionsNotDiscovered => '検出が完了していません。このアカウントを更新または再接続してください。'; + + @override + String get collectionsEmpty => 'コレクションが見つかりません。'; + + @override + String get accountReadyToSync => '同期の準備完了'; + + @override + String get accountSyncProblem => '同期への対応が必要'; + + @override + String get accountServiceUnreachable => 'サービスに接続できませんでした'; + + @override + String get accountSyncDetails => '同期の詳細'; + + @override + String get accountReconnectStatus => '再接続が必要'; + + @override + String get accountRegistrationStatus => 'アプリ登録への対応が必要'; + + @override + String get collectionsUnavailable => 'コレクションを利用できません'; + + @override + String get accountContactSuggestions => 'このアカウントの連絡先からゲストを候補に表示'; + + @override + String get accountLoadingSignInSettings => 'サインイン設定を読み込み中…'; + + @override + String get contactsNoAddressBooks => '利用できるアドレス帳がありません'; + + @override + String get contactsSourcesUnavailable => 'アドレス帳を読み込めませんでした'; + + @override + String get syncNoRecord => '成功した同期時刻の記録がありません'; + + @override + String get cachedSyncError => '同期に失敗しました。キャッシュのデータが古い可能性があります。'; + + @override + String get calendarRemotePending => 'Google の表示設定の変更を待機中'; + + @override + String get calendarRemoteFailed => + 'Google での表示設定の変更に失敗しました。Google で最後に確認された状態が表示されています。BusyMax は引き続き保留中の変更に従います。再試行するか、変更を破棄してください。'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return '「$calendar」の保留中の変更を破棄しますか?\n\n次の設定を以前の値に戻します:\n$settings\n\nBusyMaxでのカレンダーの選択は保持されます。Googleに変更は送信されません。'; + } + + @override + String get sharedCalendarUnavailable => + 'アクセスできません。キャッシュを表示しています。同期を再試行するか、リストから削除してください。'; + + @override + String get invalidCollectionColor => '# で始まる6桁または8桁の16進数の色を入力してください。'; + + @override + String get invalidCollectionOrder => '整数を入力してください。'; + + @override + String get invalidCollectionTimezone => + 'VTIMEZONE と TZID を含む有効なカレンダーのタイムゾーン文書を入力してください。'; + + @override + String get invalidCollectionValue => '保存する前にこの値を修正してください。'; + + @override + String get accountAlreadyConnected => + 'このアカウントは既に接続されています。このアカウントの再接続操作を使用してください。'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax はこのアカウントの Nextcloud アプリパスワードの失効も試みます。失効に失敗しても、ローカルでの削除は完了する場合があります。'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index e4657194..2655c364 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String get contactsAccountsUnavailable => '연락처 계정을 불러올 수 없습니다'; + @override String get launchAtLoginManagedExternally => '데스크톱의 시작 프로그램 설정에 의해 BusyMax가 실행되었습니다. 이 스위치를 사용하려면 해당 설정에서 BusyMax 항목을 제거한 다음 BusyMax를 완전히 종료하고 다시 여세요.'; @@ -2948,7 +2951,7 @@ class AppLocalizationsKo extends AppLocalizations { String get searchClearText => '텍스트 지우기'; @override - String get openSharedCalendar => '공유 캘린더 열기'; + String get openSharedCalendar => '공유 기본 캘린더 열기'; @override String get manageCalendarSharing => '캘린더 공유 관리'; @@ -3510,4 +3513,153 @@ class AppLocalizationsKo extends AppLocalizations { @override String get addSeparateGoogleAccount => '별도의 Google 연결 추가'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return '반복 패턴을 변경하면 날짜가 추가되거나 삭제될 수 있습니다.\n\n현재: $before\n제안: $after\n\n이 패턴을 저장할까요?'; + } + + @override + String get connectedAccounts => '연결된 계정'; + + @override + String get contactsIndependentAccounts => '독립 연락처 계정'; + + @override + String get contactsNextcloudReuse => + '연락처 제안은 읽기 전용 연락처 통합을 통해 이 계정의 기존 자격 증명을 재사용합니다.'; + + @override + String get contactsRetrySync => '연락처 동기화 다시 시도'; + + @override + String get contactsPreferenceFailed => '연락처 소스 설정을 저장하지 못했습니다.'; + + @override + String get contactsSuggestionsDisabled => '연락처 제안 꺼짐'; + + @override + String get contactsSyncFailed => + '연락처를 동기화하지 못했습니다. 다시 시도하거나 권한이 만료된 경우 계정을 다시 연결하세요.'; + + @override + String get registrationRecoveryDescription => + '저장된 앱 등록 또는 자격 증명 연결을 사용할 수 없거나 거부되었습니다. 복구하려면 이 계정의 등록을 명시적으로 교체하세요. 기존 데이터는 유지됩니다.'; + + @override + String get revalidateAccount => '연결 다시 검증'; + + @override + String get openSharedPrimaryDescription => + '지정한 소유자의 기본 캘린더를 엽니다. 다른 공유 캘린더는 일반 계정 동기화에서 검색됩니다.'; + + @override + String get showInBusyMax => 'BusyMax에 표시'; + + @override + String get showInGoogleCalendarList => 'Google 캘린더 목록에 표시'; + + @override + String get googleVisibilityHelp => + 'BusyMax 선택은 로컬 설정입니다. Google 표시 설정은 Google의 목록을 변경하며 숨겨진 캘린더는 BusyMax에 표시할 수 없습니다.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Google 기본 캘린더는 목록에 유지됩니다.'; + + @override + String get accountsLoading => '계정 불러오는 중…'; + + @override + String get accountsLoadFailed => '계정을 불러오지 못했습니다. 계정을 다시 여세요.'; + + @override + String get collectionsLoading => '컬렉션 불러오는 중…'; + + @override + String get collectionsLoadFailed => + '컬렉션을 불러오지 못했습니다. 캐시는 유지됩니다. 동기화를 다시 시도하세요.'; + + @override + String get collectionsNotDiscovered => + '검색이 완료되지 않았습니다. 이 계정을 새로 고치거나 다시 연결하세요.'; + + @override + String get collectionsEmpty => '컬렉션을 찾지 못했습니다.'; + + @override + String get accountReadyToSync => '동기화 준비됨'; + + @override + String get accountSyncProblem => '동기화에 조치가 필요합니다'; + + @override + String get accountServiceUnreachable => '서비스에 연결할 수 없습니다'; + + @override + String get accountSyncDetails => '동기화 세부 정보'; + + @override + String get accountReconnectStatus => '다시 연결해야 합니다'; + + @override + String get accountRegistrationStatus => '앱 등록에 조치가 필요합니다'; + + @override + String get collectionsUnavailable => '컬렉션을 사용할 수 없습니다'; + + @override + String get accountContactSuggestions => '이 계정의 연락처에서 참석자 제안'; + + @override + String get accountLoadingSignInSettings => '로그인 설정을 불러오는 중…'; + + @override + String get contactsNoAddressBooks => '사용 가능한 주소록이 없습니다'; + + @override + String get contactsSourcesUnavailable => '주소록을 불러올 수 없습니다'; + + @override + String get syncNoRecord => '성공한 동기화 시간이 기록되지 않음'; + + @override + String get cachedSyncError => '동기화 실패. 캐시 데이터가 오래되었을 수 있습니다.'; + + @override + String get calendarRemotePending => 'Google 표시 변경 대기 중'; + + @override + String get calendarRemoteFailed => + 'Google 표시 설정 변경에 실패했습니다. 마지막으로 확인된 Google 상태가 표시됩니다. BusyMax는 계속 대기 중인 변경을 따릅니다. 다시 시도하거나 변경을 취소하세요.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return '“$calendar”의 대기 중인 변경사항을 취소할까요?\n\n다음 설정이 이전 값으로 복원됩니다:\n$settings\n\nBusyMax의 캘린더 선택은 유지됩니다. Google에 변경사항이 전송되지 않습니다.'; + } + + @override + String get sharedCalendarUnavailable => + '접근할 수 없어 캐시를 표시합니다. 동기화를 다시 시도하거나 캘린더를 목록에서 제거하세요.'; + + @override + String get invalidCollectionColor => '#으로 시작하는 6자리 또는 8자리 16진수 색상을 입력하세요.'; + + @override + String get invalidCollectionOrder => '정수를 입력하세요.'; + + @override + String get invalidCollectionTimezone => + 'VTIMEZONE 및 TZID를 포함하는 올바른 캘린더 시간대 문서를 입력하세요.'; + + @override + String get invalidCollectionValue => '저장하기 전에 이 값을 수정하세요.'; + + @override + String get accountAlreadyConnected => + '이 계정은 이미 연결되어 있습니다. 해당 계정의 다시 연결 작업을 사용하세요.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax는 이 계정의 Nextcloud 앱 암호 해지도 시도합니다. 해지에 실패해도 로컬 제거는 완료될 수 있습니다.'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 7968dd5b..61ddb96a 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -13,6 +13,10 @@ import 'app_localizations.dart'; class AppLocalizationsPl extends AppLocalizations { AppLocalizationsPl([String locale = 'pl']) : super(locale); + @override + String get contactsAccountsUnavailable => + 'Nie udało się wczytać kont kontaktów'; + @override String get launchAtLoginManagedExternally => 'BusyMax został uruchomiony przez ustawienia autostartu pulpitu. Usuń stamtąd jego wpis, a następnie zakończ i ponownie uruchom BusyMax, aby użyć tego przełącznika.'; @@ -3180,7 +3184,7 @@ class AppLocalizationsPl extends AppLocalizations { String get searchClearText => 'Wyczyść tekst'; @override - String get openSharedCalendar => 'Otwórz udostępniony kalendarz'; + String get openSharedCalendar => 'Otwórz udostępniony kalendarz główny'; @override String get manageCalendarSharing => 'Zarządzaj udostępnianiem kalendarza'; @@ -3770,4 +3774,160 @@ class AppLocalizationsPl extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Dodaj osobne połączenie Google'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Ta zmiana reguły powtarzania może dodać lub usunąć daty.\n\nObecnie: $before\nPropozycja: $after\n\nZapisać tę regułę?'; + } + + @override + String get connectedAccounts => 'Połączone konta'; + + @override + String get contactsIndependentAccounts => 'Niezależne konta kontaktów'; + + @override + String get contactsNextcloudReuse => + 'Sugestie używają istniejących danych logowania tego konta przez integrację kontaktów tylko do odczytu.'; + + @override + String get contactsRetrySync => 'Ponów synchronizację kontaktów'; + + @override + String get contactsPreferenceFailed => + 'Nie udało się zapisać ustawienia źródła kontaktów.'; + + @override + String get contactsSuggestionsDisabled => 'Sugestie kontaktów wyłączone'; + + @override + String get contactsSyncFailed => + 'Nie udało się zsynchronizować kontaktów. Spróbuj ponownie lub połącz konto ponownie, jeśli autoryzacja wygasła.'; + + @override + String get registrationRecoveryDescription => + 'Zapisana rejestracja lub powiązanie danych logowania jest niedostępne albo odrzucone. Jawnie zastąp rejestrację tego konta, aby je odzyskać; dane zostaną zachowane.'; + + @override + String get revalidateAccount => 'Sprawdź połączenie ponownie'; + + @override + String get openSharedPrimaryDescription => + 'Otwiera główny kalendarz wskazanego właściciela. Inne udostępnione kalendarze są wykrywane podczas zwykłej synchronizacji konta.'; + + @override + String get showInBusyMax => 'Pokaż w BusyMax'; + + @override + String get showInGoogleCalendarList => 'Pokaż na liście kalendarzy Google'; + + @override + String get googleVisibilityHelp => + 'Wybór BusyMax jest lokalny. Widoczność Google zmienia listę w Google; ukrytych kalendarzy nie można wyświetlić w BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Główny kalendarz Google pozostaje na liście.'; + + @override + String get accountsLoading => 'Ładowanie kont…'; + + @override + String get accountsLoadFailed => + 'Nie udało się załadować kont. Otwórz Konta ponownie.'; + + @override + String get collectionsLoading => 'Ładowanie kolekcji…'; + + @override + String get collectionsLoadFailed => + 'Nie udało się załadować kolekcji. Pamięć podręczna jest zachowana; ponów synchronizację.'; + + @override + String get collectionsNotDiscovered => + 'Wykrywanie nie zostało zakończone. Odśwież lub połącz konto ponownie.'; + + @override + String get collectionsEmpty => 'Nie znaleziono kolekcji.'; + + @override + String get accountReadyToSync => 'Gotowe do synchronizacji'; + + @override + String get accountSyncProblem => 'Synchronizacja wymaga uwagi'; + + @override + String get accountServiceUnreachable => 'Nie można połączyć się z usługą'; + + @override + String get accountSyncDetails => 'Szczegóły synchronizacji'; + + @override + String get accountReconnectStatus => 'Wymagane ponowne połączenie'; + + @override + String get accountRegistrationStatus => 'Rejestracja aplikacji wymaga uwagi'; + + @override + String get collectionsUnavailable => 'Kolekcje niedostępne'; + + @override + String get accountContactSuggestions => + 'Sugeruj gości z kontaktów tego konta'; + + @override + String get accountLoadingSignInSettings => 'Wczytywanie ustawień logowania…'; + + @override + String get contactsNoAddressBooks => 'Brak dostępnych książek adresowych'; + + @override + String get contactsSourcesUnavailable => + 'Nie można wczytać książek adresowych'; + + @override + String get syncNoRecord => 'Brak zapisanego czasu udanej synchronizacji'; + + @override + String get cachedSyncError => + 'Synchronizacja nie powiodła się; dane w pamięci podręcznej mogą być nieaktualne.'; + + @override + String get calendarRemotePending => + 'Oczekiwanie na zmianę widoczności Google'; + + @override + String get calendarRemoteFailed => + 'Zmiana widoczności w Google nie powiodła się. Wyświetlany jest ostatni potwierdzony stan Google. BusyMax nadal stosuje oczekującą zmianę. Ponów próbę lub odrzuć zmianę.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Odrzucić oczekujące zmiany kalendarza „$calendar”?\n\nTe ustawienia wrócą do poprzednich wartości:\n$settings\n\nWybór kalendarzy w BusyMax zostanie zachowany. Żadne zmiany nie zostaną wysłane do Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Dostęp niedostępny; wyświetlane są dane z pamięci podręcznej. Ponów synchronizację lub usuń kalendarz z listy.'; + + @override + String get invalidCollectionColor => + 'Wpisz sześcio- lub ośmiocyfrowy kolor szesnastkowy zaczynający się od #.'; + + @override + String get invalidCollectionOrder => 'Wpisz liczbę całkowitą.'; + + @override + String get invalidCollectionTimezone => + 'Wpisz prawidłowy dokument strefy czasowej zawierający VTIMEZONE i TZID.'; + + @override + String get invalidCollectionValue => 'Popraw tę wartość przed zapisaniem.'; + + @override + String get accountAlreadyConnected => + 'To konto jest już połączone. Użyj jego opcji ponownego połączenia.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax spróbuje też unieważnić hasło aplikacji Nextcloud tego konta. Usunięcie lokalne może się udać nawet wtedy, gdy unieważnienie się nie powiedzie.'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 5bf8633c..45ac28c5 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get contactsAccountsUnavailable => 'Could not load contact accounts'; + @override String get launchAtLoginManagedExternally => 'BusyMax was launched by your desktop\'s startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.'; @@ -3038,7 +3041,7 @@ class AppLocalizationsPt extends AppLocalizations { String get searchClearText => 'Clear text'; @override - String get openSharedCalendar => 'Open shared calendar'; + String get openSharedCalendar => 'Open shared primary calendar'; @override String get manageCalendarSharing => 'Manage calendar sharing'; @@ -3618,12 +3621,169 @@ class AppLocalizationsPt extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Add a separate Google connection'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'This changes the recurrence pattern and may add or remove dates.\n\nCurrent: $before\nProposed: $after\n\nSave this pattern?'; + } + + @override + String get connectedAccounts => 'Connected accounts'; + + @override + String get contactsIndependentAccounts => 'Independent contact accounts'; + + @override + String get contactsNextcloudReuse => + 'Contact suggestions reuse this account’s existing credential through the read-only contacts integration.'; + + @override + String get contactsRetrySync => 'Retry contacts sync'; + + @override + String get contactsPreferenceFailed => + 'Could not save contact source preference'; + + @override + String get contactsSuggestionsDisabled => 'Contact suggestions disabled'; + + @override + String get contactsSyncFailed => + 'Contacts could not sync. Retry, or reconnect if authorization has expired.'; + + @override + String get registrationRecoveryDescription => + 'The saved registration or its credential binding is unavailable or rejected. Explicitly replace the registration for this account to recover; existing data is preserved.'; + + @override + String get revalidateAccount => 'Revalidate connection'; + + @override + String get openSharedPrimaryDescription => + 'Opens the specified owner’s primary calendar. Other shared calendars are discovered through ordinary account synchronization.'; + + @override + String get showInBusyMax => 'Show in BusyMax'; + + @override + String get showInGoogleCalendarList => 'Show in Google’s calendar list'; + + @override + String get googleVisibilityHelp => + 'BusyMax selection is local. Google visibility changes your calendar list on Google; hidden calendars cannot be displayed in BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Google’s primary calendar stays in its calendar list.'; + + @override + String get accountsLoading => 'Loading accounts…'; + + @override + String get accountsLoadFailed => + 'Accounts could not be loaded. Retry opening Accounts.'; + + @override + String get collectionsLoading => 'Loading collections…'; + + @override + String get collectionsLoadFailed => + 'Collections could not be loaded. Cached data is retained; retry synchronization.'; + + @override + String get collectionsNotDiscovered => + 'Discovery has not completed. Refresh or reconnect this account.'; + + @override + String get collectionsEmpty => 'No collections found.'; + + @override + String get accountReadyToSync => 'Ready to sync'; + + @override + String get accountSyncProblem => 'Synchronization needs attention'; + + @override + String get accountServiceUnreachable => 'Could not reach the service'; + + @override + String get accountSyncDetails => 'Synchronization details'; + + @override + String get accountReconnectStatus => 'Reconnect required'; + + @override + String get accountRegistrationStatus => 'Registration needs attention'; + + @override + String get collectionsUnavailable => 'Collections unavailable'; + + @override + String get accountContactSuggestions => + 'Suggest guests from this account’s contacts'; + + @override + String get accountLoadingSignInSettings => 'Loading sign-in settings…'; + + @override + String get contactsNoAddressBooks => 'No address books available'; + + @override + String get contactsSourcesUnavailable => 'Could not load address books'; + + @override + String get syncNoRecord => 'No successful sync time recorded'; + + @override + String get cachedSyncError => 'Sync failed; cached data may be out of date.'; + + @override + String get calendarRemotePending => 'Google visibility change pending'; + + @override + String get calendarRemoteFailed => + 'Google visibility change failed. Google’s last confirmed state is shown. BusyMax still follows the pending change. Retry or discard it.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Discard the pending changes to “$calendar”?\n\nThese settings will return to their previous values:\n$settings\n\nYour BusyMax calendar selection is kept. No changes will be sent to Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Access unavailable; showing cached data. Retry synchronization or remove from your calendar list.'; + + @override + String get invalidCollectionColor => + 'Enter a six- or eight-digit hexadecimal color, starting with #.'; + + @override + String get invalidCollectionOrder => 'Enter a whole number.'; + + @override + String get invalidCollectionTimezone => + 'Enter a valid calendar timezone document containing VTIMEZONE and TZID.'; + + @override + String get invalidCollectionValue => 'Correct this value before saving.'; + + @override + String get accountAlreadyConnected => + 'This account is already connected. Use its Reconnect action.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax will also try to revoke this account’s Nextcloud app password. Local removal can succeed even if that revocation fails.'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). class AppLocalizationsPtPt extends AppLocalizationsPt { AppLocalizationsPtPt() : super('pt_PT'); + @override + String get contactsAccountsUnavailable => + 'Não foi possível carregar as contas de contactos'; + @override String get launchAtLoginManagedExternally => 'O BusyMax foi iniciado pelas definições de arranque do ambiente de trabalho. Remova a respetiva entrada, depois saia do BusyMax e volte a abri-lo para utilizar este interruptor.'; @@ -6637,7 +6797,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get searchClearText => 'Limpar texto'; @override - String get openSharedCalendar => 'Abrir calendário partilhado'; + String get openSharedCalendar => 'Abrir calendário principal partilhado'; @override String get manageCalendarSharing => 'Gerir partilha do calendário'; @@ -7228,4 +7388,165 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get addSeparateGoogleAccount => 'Adicionar ligação Google separada'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Esta alteração da recorrência pode adicionar ou remover datas.\n\nAtual: $before\nProposta: $after\n\nGuardar esta recorrência?'; + } + + @override + String get connectedAccounts => 'Contas ligadas'; + + @override + String get contactsIndependentAccounts => 'Contas de contactos independentes'; + + @override + String get contactsNextcloudReuse => + 'As sugestões reutilizam as credenciais desta conta através da integração de contactos só de leitura.'; + + @override + String get contactsRetrySync => 'Repetir sincronização de contactos'; + + @override + String get contactsPreferenceFailed => + 'Não foi possível guardar a preferência da origem de contactos.'; + + @override + String get contactsSuggestionsDisabled => + 'Sugestões de contactos desativadas'; + + @override + String get contactsSyncFailed => + 'Não foi possível sincronizar os contactos. Tente novamente ou volte a ligar a conta se a autorização expirou.'; + + @override + String get registrationRecoveryDescription => + 'O registo guardado ou a associação às credenciais está indisponível ou foi rejeitado. Substitua explicitamente o registo desta conta para a recuperar; os dados são preservados.'; + + @override + String get revalidateAccount => 'Revalidar ligação'; + + @override + String get openSharedPrimaryDescription => + 'Abre o calendário principal do proprietário indicado. Outros calendários partilhados são detetados na sincronização normal da conta.'; + + @override + String get showInBusyMax => 'Mostrar no BusyMax'; + + @override + String get showInGoogleCalendarList => + 'Mostrar na lista de calendários do Google'; + + @override + String get googleVisibilityHelp => + 'A seleção do BusyMax é local. A visibilidade do Google altera a sua lista no Google; calendários ocultos não podem ser apresentados no BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'O calendário principal do Google permanece na lista.'; + + @override + String get accountsLoading => 'A carregar contas…'; + + @override + String get accountsLoadFailed => + 'Não foi possível carregar as contas. Volte a abrir Contas.'; + + @override + String get collectionsLoading => 'A carregar coleções…'; + + @override + String get collectionsLoadFailed => + 'Não foi possível carregar as coleções. Os dados em cache são preservados; repita a sincronização.'; + + @override + String get collectionsNotDiscovered => + 'A descoberta não terminou. Atualize ou volte a ligar esta conta.'; + + @override + String get collectionsEmpty => 'Não foram encontradas coleções.'; + + @override + String get accountReadyToSync => 'Pronto para sincronizar'; + + @override + String get accountSyncProblem => 'A sincronização requer atenção'; + + @override + String get accountServiceUnreachable => + 'Não foi possível contactar o serviço'; + + @override + String get accountSyncDetails => 'Detalhes de sincronização'; + + @override + String get accountReconnectStatus => 'É necessário voltar a ligar'; + + @override + String get accountRegistrationStatus => 'O registo requer atenção'; + + @override + String get collectionsUnavailable => 'Coleções indisponíveis'; + + @override + String get accountContactSuggestions => + 'Sugerir convidados a partir dos contactos desta conta'; + + @override + String get accountLoadingSignInSettings => + 'A carregar as definições de início de sessão…'; + + @override + String get contactsNoAddressBooks => 'Nenhum livro de endereços disponível'; + + @override + String get contactsSourcesUnavailable => + 'Não foi possível carregar os livros de endereços'; + + @override + String get syncNoRecord => + 'Nenhuma data de sincronização bem-sucedida registada'; + + @override + String get cachedSyncError => + 'A sincronização falhou; os dados em cache podem estar desatualizados.'; + + @override + String get calendarRemotePending => + 'Alteração de visibilidade no Google pendente'; + + @override + String get calendarRemoteFailed => + 'A alteração de visibilidade no Google falhou. É apresentado o último estado confirmado do Google. O BusyMax continua a seguir a alteração pendente. Tente novamente ou descarte a alteração.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Descartar as alterações pendentes de «$calendar»?\n\nEstas definições regressarão aos valores anteriores:\n$settings\n\nA sua seleção de calendários no BusyMax será mantida. Não serão enviadas alterações à Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Acesso indisponível; a mostrar dados em cache. Repita a sincronização ou remova o calendário da sua lista.'; + + @override + String get invalidCollectionColor => + 'Introduza uma cor hexadecimal de seis ou oito dígitos, começando por #.'; + + @override + String get invalidCollectionOrder => 'Introduza um número inteiro.'; + + @override + String get invalidCollectionTimezone => + 'Introduza um documento de fuso horário válido com VTIMEZONE e TZID.'; + + @override + String get invalidCollectionValue => 'Corrija este valor antes de guardar.'; + + @override + String get accountAlreadyConnected => + 'Esta conta já está ligada. Utilize a ação para voltar a ligar.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'O BusyMax tentará também revogar a palavra-passe de aplicação Nextcloud desta conta. A remoção local pode ser concluída mesmo que a revogação falhe.'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 311e6ac5..a3867b5f 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -13,6 +13,10 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get contactsAccountsUnavailable => + 'Не удалось загрузить учётные записи контактов'; + @override String get launchAtLoginManagedExternally => 'BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.'; @@ -3030,7 +3034,7 @@ class AppLocalizationsRu extends AppLocalizations { String get searchClearText => 'Очистить текст'; @override - String get openSharedCalendar => 'Открыть общий календарь'; + String get openSharedCalendar => 'Открыть общий основной календарь'; @override String get manageCalendarSharing => 'Управление доступом к календарю'; @@ -3630,4 +3634,162 @@ class AppLocalizationsRu extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Добавить отдельное подключение Google'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Изменение правила повторения может добавить или удалить даты.\n\nСейчас: $before\nПредложено: $after\n\nСохранить это правило?'; + } + + @override + String get connectedAccounts => 'Подключённые аккаунты'; + + @override + String get contactsIndependentAccounts => 'Независимые аккаунты контактов'; + + @override + String get contactsNextcloudReuse => + 'Подсказки используют существующие учётные данные этого аккаунта через интеграцию контактов только для чтения.'; + + @override + String get contactsRetrySync => 'Повторить синхронизацию контактов'; + + @override + String get contactsPreferenceFailed => + 'Не удалось сохранить настройку источника контактов.'; + + @override + String get contactsSuggestionsDisabled => 'Подсказки контактов отключены'; + + @override + String get contactsSyncFailed => + 'Не удалось синхронизировать контакты. Повторите попытку или переподключите аккаунт, если авторизация истекла.'; + + @override + String get registrationRecoveryDescription => + 'Сохранённая регистрация или привязка учётных данных недоступна либо отклонена. Для восстановления явно замените регистрацию этого аккаунта; данные сохранятся.'; + + @override + String get revalidateAccount => 'Проверить подключение'; + + @override + String get openSharedPrimaryDescription => + 'Открывает основной календарь указанного владельца. Другие общие календари обнаруживаются при обычной синхронизации аккаунта.'; + + @override + String get showInBusyMax => 'Показывать в BusyMax'; + + @override + String get showInGoogleCalendarList => + 'Показывать в списке календарей Google'; + + @override + String get googleVisibilityHelp => + 'Выбор BusyMax действует локально. Видимость Google меняет ваш список в Google; скрытые календари нельзя показать в BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Основной календарь Google остаётся в списке.'; + + @override + String get accountsLoading => 'Загрузка аккаунтов…'; + + @override + String get accountsLoadFailed => + 'Не удалось загрузить аккаунты. Откройте раздел «Аккаунты» заново.'; + + @override + String get collectionsLoading => 'Загрузка коллекций…'; + + @override + String get collectionsLoadFailed => + 'Не удалось загрузить коллекции. Кэш сохранён; повторите синхронизацию.'; + + @override + String get collectionsNotDiscovered => + 'Обнаружение ещё не завершено. Обновите или переподключите аккаунт.'; + + @override + String get collectionsEmpty => 'Коллекции не найдены.'; + + @override + String get accountReadyToSync => 'Готово к синхронизации'; + + @override + String get accountSyncProblem => 'Синхронизация требует внимания'; + + @override + String get accountServiceUnreachable => 'Не удалось связаться с сервисом'; + + @override + String get accountSyncDetails => 'Подробности синхронизации'; + + @override + String get accountReconnectStatus => 'Требуется повторное подключение'; + + @override + String get accountRegistrationStatus => + 'Регистрация приложения требует внимания'; + + @override + String get collectionsUnavailable => 'Коллекции недоступны'; + + @override + String get accountContactSuggestions => + 'Предлагать гостей из контактов этого аккаунта'; + + @override + String get accountLoadingSignInSettings => 'Загрузка настроек входа…'; + + @override + String get contactsNoAddressBooks => 'Нет доступных адресных книг'; + + @override + String get contactsSourcesUnavailable => + 'Не удалось загрузить адресные книги'; + + @override + String get syncNoRecord => 'Время успешной синхронизации не записано'; + + @override + String get cachedSyncError => + 'Сбой синхронизации; данные в кэше могут быть устаревшими.'; + + @override + String get calendarRemotePending => 'Ожидается изменение видимости в Google'; + + @override + String get calendarRemoteFailed => + 'Не удалось изменить видимость в Google. Показано последнее подтверждённое состояние Google. BusyMax по-прежнему учитывает ожидающее изменение. Повторите попытку или отмените изменение.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Отменить ожидающие изменения календаря «$calendar»?\n\nБудут восстановлены предыдущие значения этих настроек:\n$settings\n\nВыбор календарей в BusyMax сохранится. Изменения не будут отправлены в Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Нет доступа; показаны данные из кэша. Повторите синхронизацию или удалите календарь из своего списка.'; + + @override + String get invalidCollectionColor => + 'Введите шестизначный или восьмизначный шестнадцатеричный цвет, начиная с #.'; + + @override + String get invalidCollectionOrder => 'Введите целое число.'; + + @override + String get invalidCollectionTimezone => + 'Введите корректный документ часового пояса с VTIMEZONE и TZID.'; + + @override + String get invalidCollectionValue => + 'Исправьте это значение перед сохранением.'; + + @override + String get accountAlreadyConnected => + 'Этот аккаунт уже подключён. Используйте его действие переподключения.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax также попытается отозвать пароль приложения Nextcloud этого аккаунта. Локальное удаление может завершиться, даже если отзыв не удался.'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index eb83a942..5d8a9cb3 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -13,6 +13,10 @@ import 'app_localizations.dart'; class AppLocalizationsVi extends AppLocalizations { AppLocalizationsVi([String locale = 'vi']) : super(locale); + @override + String get contactsAccountsUnavailable => + 'Không thể tải các tài khoản danh bạ'; + @override String get launchAtLoginManagedExternally => 'BusyMax được mở bởi cài đặt khởi động của môi trường máy tính. Xóa mục BusyMax ở đó, rồi thoát hẳn và mở lại BusyMax để dùng công tắc này.'; @@ -3003,7 +3007,7 @@ class AppLocalizationsVi extends AppLocalizations { String get searchClearText => 'Xóa văn bản'; @override - String get openSharedCalendar => 'Mở lịch được chia sẻ'; + String get openSharedCalendar => 'Mở lịch chính được chia sẻ'; @override String get manageCalendarSharing => 'Quản lý chia sẻ lịch'; @@ -3585,4 +3589,157 @@ class AppLocalizationsVi extends AppLocalizations { @override String get addSeparateGoogleAccount => 'Thêm kết nối Google riêng'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return 'Thay đổi kiểu lặp có thể thêm hoặc xóa ngày.\n\nHiện tại: $before\nĐề xuất: $after\n\nLưu kiểu lặp này?'; + } + + @override + String get connectedAccounts => 'Tài khoản đã kết nối'; + + @override + String get contactsIndependentAccounts => 'Tài khoản danh bạ độc lập'; + + @override + String get contactsNextcloudReuse => + 'Gợi ý liên hệ dùng lại thông tin xác thực hiện có của tài khoản qua tích hợp danh bạ chỉ đọc.'; + + @override + String get contactsRetrySync => 'Thử đồng bộ danh bạ lại'; + + @override + String get contactsPreferenceFailed => + 'Không thể lưu tùy chọn nguồn danh bạ.'; + + @override + String get contactsSuggestionsDisabled => 'Đã tắt gợi ý liên hệ'; + + @override + String get contactsSyncFailed => + 'Không thể đồng bộ danh bạ. Thử lại hoặc kết nối lại tài khoản nếu quyền đã hết hạn.'; + + @override + String get registrationRecoveryDescription => + 'Đăng ký đã lưu hoặc liên kết thông tin xác thực không khả dụng hoặc bị từ chối. Hãy thay thế rõ ràng đăng ký của tài khoản này để khôi phục; dữ liệu hiện có được giữ lại.'; + + @override + String get revalidateAccount => 'Xác minh lại kết nối'; + + @override + String get openSharedPrimaryDescription => + 'Mở lịch chính của chủ sở hữu được chỉ định. Các lịch chia sẻ khác được tìm qua đồng bộ tài khoản thông thường.'; + + @override + String get showInBusyMax => 'Hiện trong BusyMax'; + + @override + String get showInGoogleCalendarList => 'Hiện trong danh sách lịch Google'; + + @override + String get googleVisibilityHelp => + 'Lựa chọn BusyMax là cục bộ. Chế độ hiển thị Google thay đổi danh sách trên Google; lịch bị ẩn không thể hiển thị trong BusyMax.'; + + @override + String get primaryCalendarVisibilityExplanation => + 'Lịch chính Google luôn nằm trong danh sách.'; + + @override + String get accountsLoading => 'Đang tải tài khoản…'; + + @override + String get accountsLoadFailed => + 'Không thể tải tài khoản. Hãy mở lại Tài khoản.'; + + @override + String get collectionsLoading => 'Đang tải bộ sưu tập…'; + + @override + String get collectionsLoadFailed => + 'Không thể tải bộ sưu tập. Dữ liệu đệm được giữ; hãy thử đồng bộ lại.'; + + @override + String get collectionsNotDiscovered => + 'Chưa hoàn tất phát hiện. Hãy làm mới hoặc kết nối lại tài khoản.'; + + @override + String get collectionsEmpty => 'Không tìm thấy bộ sưu tập.'; + + @override + String get accountReadyToSync => 'Sẵn sàng đồng bộ'; + + @override + String get accountSyncProblem => 'Đồng bộ cần được xử lý'; + + @override + String get accountServiceUnreachable => 'Không thể kết nối với dịch vụ'; + + @override + String get accountSyncDetails => 'Chi tiết đồng bộ'; + + @override + String get accountReconnectStatus => 'Cần kết nối lại'; + + @override + String get accountRegistrationStatus => 'Đăng ký ứng dụng cần được xử lý'; + + @override + String get collectionsUnavailable => 'Bộ sưu tập không khả dụng'; + + @override + String get accountContactSuggestions => + 'Đề xuất khách từ danh bạ của tài khoản này'; + + @override + String get accountLoadingSignInSettings => 'Đang tải cài đặt đăng nhập…'; + + @override + String get contactsNoAddressBooks => 'Không có sổ địa chỉ nào khả dụng'; + + @override + String get contactsSourcesUnavailable => 'Không thể tải sổ địa chỉ'; + + @override + String get syncNoRecord => 'Chưa ghi nhận thời điểm đồng bộ thành công'; + + @override + String get cachedSyncError => 'Đồng bộ thất bại; dữ liệu đệm có thể đã cũ.'; + + @override + String get calendarRemotePending => 'Đang chờ đổi chế độ hiển thị Google'; + + @override + String get calendarRemoteFailed => + 'Thay đổi khả năng hiển thị trên Google không thành công. Trạng thái Google được xác nhận gần nhất đang hiển thị. BusyMax vẫn áp dụng thay đổi đang chờ. Hãy thử lại hoặc hủy thay đổi.'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return 'Hủy các thay đổi đang chờ của “$calendar”?\n\nCác cài đặt sau sẽ trở về giá trị trước đó:\n$settings\n\nLựa chọn lịch trong BusyMax của bạn được giữ nguyên. Không có thay đổi nào được gửi đến Google.'; + } + + @override + String get sharedCalendarUnavailable => + 'Không thể truy cập; đang hiện dữ liệu đệm. Thử đồng bộ lại hoặc xóa khỏi danh sách lịch.'; + + @override + String get invalidCollectionColor => + 'Nhập màu thập lục phân gồm sáu hoặc tám chữ số, bắt đầu bằng #.'; + + @override + String get invalidCollectionOrder => 'Nhập số nguyên.'; + + @override + String get invalidCollectionTimezone => + 'Nhập tài liệu múi giờ lịch hợp lệ chứa VTIMEZONE và TZID.'; + + @override + String get invalidCollectionValue => 'Sửa giá trị này trước khi lưu.'; + + @override + String get accountAlreadyConnected => + 'Tài khoản này đã kết nối. Hãy dùng thao tác kết nối lại của tài khoản.'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax cũng sẽ thử thu hồi mật khẩu ứng dụng Nextcloud của tài khoản này. Việc xóa cục bộ vẫn có thể thành công dù thu hồi thất bại.'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index b93b8852..ad9f5ed7 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -13,6 +13,9 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get contactsAccountsUnavailable => '无法加载联系人账户'; + @override String get launchAtLoginManagedExternally => 'BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。'; @@ -2905,7 +2908,7 @@ class AppLocalizationsZh extends AppLocalizations { String get searchClearText => '清除文字'; @override - String get openSharedCalendar => '打开共享日历'; + String get openSharedCalendar => '打开共享主日历'; @override String get manageCalendarSharing => '管理日历共享'; @@ -3454,12 +3457,155 @@ class AppLocalizationsZh extends AppLocalizations { @override String get addSeparateGoogleAccount => '添加独立的 Google 连接'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return '更改重复规则可能会增加或移除日期。\n\n当前:$before\n建议:$after\n\n保存此规则吗?'; + } + + @override + String get connectedAccounts => '已连接的账户'; + + @override + String get contactsIndependentAccounts => '独立联系人账户'; + + @override + String get contactsNextcloudReuse => '联系人建议通过只读联系人集成重复使用此账户现有的凭据。'; + + @override + String get contactsRetrySync => '重试联系人同步'; + + @override + String get contactsPreferenceFailed => '无法保存联系人来源偏好。'; + + @override + String get contactsSuggestionsDisabled => '联系人建议已停用'; + + @override + String get contactsSyncFailed => '无法同步联系人。请重试,或在授权过期时重新连接账户。'; + + @override + String get registrationRecoveryDescription => + '保存的应用注册或凭据绑定不可用或被拒绝。请明确替换此账户的注册以恢复连接;现有数据会保留。'; + + @override + String get revalidateAccount => '重新验证连接'; + + @override + String get openSharedPrimaryDescription => '打开指定所有者的主日历。其他共享日历通过普通账户同步发现。'; + + @override + String get showInBusyMax => '在 BusyMax 中显示'; + + @override + String get showInGoogleCalendarList => '在 Google 日历列表中显示'; + + @override + String get googleVisibilityHelp => + 'BusyMax 的选择是本地设置。Google 可见性会更改 Google 上的日历列表;隐藏的日历无法在 BusyMax 中显示。'; + + @override + String get primaryCalendarVisibilityExplanation => 'Google 主日历会保留在日历列表中。'; + + @override + String get accountsLoading => '正在加载账户…'; + + @override + String get accountsLoadFailed => '无法加载账户。请重新打开“账户”。'; + + @override + String get collectionsLoading => '正在加载集合…'; + + @override + String get collectionsLoadFailed => '无法加载集合。缓存数据已保留;请重试同步。'; + + @override + String get collectionsNotDiscovered => '发现尚未完成。请刷新或重新连接此账户。'; + + @override + String get collectionsEmpty => '未找到集合。'; + + @override + String get accountReadyToSync => '已准备好同步'; + + @override + String get accountSyncProblem => '同步需要处理'; + + @override + String get accountServiceUnreachable => '无法连接到服务'; + + @override + String get accountSyncDetails => '同步详情'; + + @override + String get accountReconnectStatus => '需要重新连接'; + + @override + String get accountRegistrationStatus => '应用注册需要处理'; + + @override + String get collectionsUnavailable => '集合不可用'; + + @override + String get accountContactSuggestions => '从此账户的联系人中推荐访客'; + + @override + String get accountLoadingSignInSettings => '正在加载登录设置…'; + + @override + String get contactsNoAddressBooks => '没有可用的通讯录'; + + @override + String get contactsSourcesUnavailable => '无法加载通讯录'; + + @override + String get syncNoRecord => '没有成功同步时间记录'; + + @override + String get cachedSyncError => '同步失败;缓存数据可能已过时。'; + + @override + String get calendarRemotePending => 'Google 可见性更改待处理'; + + @override + String get calendarRemoteFailed => + 'Google 显示设置更改失败。现显示 Google 上次确认的状态。BusyMax 仍遵循待处理的更改。请重试或放弃该更改。'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return '要放弃“$calendar”的待处理更改吗?\n\n以下设置将恢复为之前的值:\n$settings\n\nBusyMax 中的日历选择将保留。不会向 Google 发送任何更改。'; + } + + @override + String get sharedCalendarUnavailable => '无法访问;正在显示缓存数据。请重试同步或从日历列表中移除。'; + + @override + String get invalidCollectionColor => '请输入以 # 开头的六位或八位十六进制颜色。'; + + @override + String get invalidCollectionOrder => '请输入整数。'; + + @override + String get invalidCollectionTimezone => '请输入包含 VTIMEZONE 和 TZID 的有效日历时区文档。'; + + @override + String get invalidCollectionValue => '保存前请修正此值。'; + + @override + String get accountAlreadyConnected => '此账户已连接。请使用该账户的“重新连接”操作。'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax 还会尝试撤销此账户的 Nextcloud 应用密码。即使撤销失败,本地移除也可以成功。'; } /// The translations for Chinese, using the Han script (`zh_Hans`). class AppLocalizationsZhHans extends AppLocalizationsZh { AppLocalizationsZhHans() : super('zh_Hans'); + @override + String get contactsAccountsUnavailable => '无法加载联系人账户'; + @override String get launchAtLoginManagedExternally => 'BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。'; @@ -6352,7 +6498,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get searchClearText => '清除文字'; @override - String get openSharedCalendar => '打开共享日历'; + String get openSharedCalendar => '打开共享主日历'; @override String get manageCalendarSharing => '管理日历共享'; @@ -6901,12 +7047,155 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get addSeparateGoogleAccount => '添加独立的 Google 连接'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return '更改重复规则可能会增加或移除日期。\n\n当前:$before\n建议:$after\n\n保存此规则吗?'; + } + + @override + String get connectedAccounts => '已连接的账户'; + + @override + String get contactsIndependentAccounts => '独立联系人账户'; + + @override + String get contactsNextcloudReuse => '联系人建议通过只读联系人集成重复使用此账户现有的凭据。'; + + @override + String get contactsRetrySync => '重试联系人同步'; + + @override + String get contactsPreferenceFailed => '无法保存联系人来源偏好。'; + + @override + String get contactsSuggestionsDisabled => '联系人建议已停用'; + + @override + String get contactsSyncFailed => '无法同步联系人。请重试,或在授权过期时重新连接账户。'; + + @override + String get registrationRecoveryDescription => + '保存的应用注册或凭据绑定不可用或被拒绝。请明确替换此账户的注册以恢复连接;现有数据会保留。'; + + @override + String get revalidateAccount => '重新验证连接'; + + @override + String get openSharedPrimaryDescription => '打开指定所有者的主日历。其他共享日历通过普通账户同步发现。'; + + @override + String get showInBusyMax => '在 BusyMax 中显示'; + + @override + String get showInGoogleCalendarList => '在 Google 日历列表中显示'; + + @override + String get googleVisibilityHelp => + 'BusyMax 的选择是本地设置。Google 可见性会更改 Google 上的日历列表;隐藏的日历无法在 BusyMax 中显示。'; + + @override + String get primaryCalendarVisibilityExplanation => 'Google 主日历会保留在日历列表中。'; + + @override + String get accountsLoading => '正在加载账户…'; + + @override + String get accountsLoadFailed => '无法加载账户。请重新打开“账户”。'; + + @override + String get collectionsLoading => '正在加载集合…'; + + @override + String get collectionsLoadFailed => '无法加载集合。缓存数据已保留;请重试同步。'; + + @override + String get collectionsNotDiscovered => '发现尚未完成。请刷新或重新连接此账户。'; + + @override + String get collectionsEmpty => '未找到集合。'; + + @override + String get accountReadyToSync => '已准备好同步'; + + @override + String get accountSyncProblem => '同步需要处理'; + + @override + String get accountServiceUnreachable => '无法连接到服务'; + + @override + String get accountSyncDetails => '同步详情'; + + @override + String get accountReconnectStatus => '需要重新连接'; + + @override + String get accountRegistrationStatus => '应用注册需要处理'; + + @override + String get collectionsUnavailable => '集合不可用'; + + @override + String get accountContactSuggestions => '从此账户的联系人中推荐访客'; + + @override + String get accountLoadingSignInSettings => '正在加载登录设置…'; + + @override + String get contactsNoAddressBooks => '没有可用的通讯录'; + + @override + String get contactsSourcesUnavailable => '无法加载通讯录'; + + @override + String get syncNoRecord => '没有成功同步时间记录'; + + @override + String get cachedSyncError => '同步失败;缓存数据可能已过时。'; + + @override + String get calendarRemotePending => 'Google 可见性更改待处理'; + + @override + String get calendarRemoteFailed => + 'Google 显示设置更改失败。现显示 Google 上次确认的状态。BusyMax 仍遵循待处理的更改。请重试或放弃该更改。'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return '要放弃“$calendar”的待处理更改吗?\n\n以下设置将恢复为之前的值:\n$settings\n\nBusyMax 中的日历选择将保留。不会向 Google 发送任何更改。'; + } + + @override + String get sharedCalendarUnavailable => '无法访问;正在显示缓存数据。请重试同步或从日历列表中移除。'; + + @override + String get invalidCollectionColor => '请输入以 # 开头的六位或八位十六进制颜色。'; + + @override + String get invalidCollectionOrder => '请输入整数。'; + + @override + String get invalidCollectionTimezone => '请输入包含 VTIMEZONE 和 TZID 的有效日历时区文档。'; + + @override + String get invalidCollectionValue => '保存前请修正此值。'; + + @override + String get accountAlreadyConnected => '此账户已连接。请使用该账户的“重新连接”操作。'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax 还会尝试撤销此账户的 Nextcloud 应用密码。即使撤销失败,本地移除也可以成功。'; } /// The translations for Chinese, using the Han script (`zh_Hant`). class AppLocalizationsZhHant extends AppLocalizationsZh { AppLocalizationsZhHant() : super('zh_Hant'); + @override + String get contactsAccountsUnavailable => '無法載入聯絡人帳號'; + @override String get launchAtLoginManagedExternally => 'BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。'; @@ -9800,7 +10089,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get searchClearText => '清除文字'; @override - String get openSharedCalendar => '開啟共用行事曆'; + String get openSharedCalendar => '開啟共用主要行事曆'; @override String get manageCalendarSharing => '管理行事曆共用'; @@ -10349,4 +10638,144 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get addSeparateGoogleAccount => '新增獨立的 Google 連線'; + + @override + String seriesPatternChangeConfirmation(String before, String after) { + return '變更重複規則可能會增加或移除日期。\n\n目前:$before\n建議:$after\n\n要儲存此規則嗎?'; + } + + @override + String get connectedAccounts => '已連線的帳戶'; + + @override + String get contactsIndependentAccounts => '獨立聯絡人帳戶'; + + @override + String get contactsNextcloudReuse => '聯絡人建議透過唯讀聯絡人整合重複使用此帳戶現有的憑證。'; + + @override + String get contactsRetrySync => '重試聯絡人同步'; + + @override + String get contactsPreferenceFailed => '無法儲存聯絡人來源偏好。'; + + @override + String get contactsSuggestionsDisabled => '聯絡人建議已停用'; + + @override + String get contactsSyncFailed => '無法同步聯絡人。請重試,或在授權過期時重新連線帳戶。'; + + @override + String get registrationRecoveryDescription => + '儲存的應用程式註冊或憑證繫結無法使用或遭拒。請明確取代此帳戶的註冊以恢復連線;現有資料會保留。'; + + @override + String get revalidateAccount => '重新驗證連線'; + + @override + String get openSharedPrimaryDescription => '開啟指定擁有者的主要行事曆。其他共用行事曆透過一般帳戶同步尋找。'; + + @override + String get showInBusyMax => '在 BusyMax 中顯示'; + + @override + String get showInGoogleCalendarList => '在 Google 行事曆清單中顯示'; + + @override + String get googleVisibilityHelp => + 'BusyMax 的選擇是本機設定。Google 可見性會變更 Google 上的清單;隱藏的行事曆無法在 BusyMax 中顯示。'; + + @override + String get primaryCalendarVisibilityExplanation => 'Google 主要行事曆會保留在清單中。'; + + @override + String get accountsLoading => '正在載入帳戶…'; + + @override + String get accountsLoadFailed => '無法載入帳戶。請重新開啟「帳戶」。'; + + @override + String get collectionsLoading => '正在載入集合…'; + + @override + String get collectionsLoadFailed => '無法載入集合。快取資料已保留;請重試同步。'; + + @override + String get collectionsNotDiscovered => '探索尚未完成。請重新整理或重新連線此帳戶。'; + + @override + String get collectionsEmpty => '找不到集合。'; + + @override + String get accountReadyToSync => '已準備好同步'; + + @override + String get accountSyncProblem => '同步需要處理'; + + @override + String get accountServiceUnreachable => '無法連線至服務'; + + @override + String get accountSyncDetails => '同步詳細資料'; + + @override + String get accountReconnectStatus => '需要重新連線'; + + @override + String get accountRegistrationStatus => '應用程式註冊需要處理'; + + @override + String get collectionsUnavailable => '集合無法使用'; + + @override + String get accountContactSuggestions => '從此帳戶的聯絡人中建議訪客'; + + @override + String get accountLoadingSignInSettings => '正在載入登入設定…'; + + @override + String get contactsNoAddressBooks => '沒有可用的通訊錄'; + + @override + String get contactsSourcesUnavailable => '無法載入通訊錄'; + + @override + String get syncNoRecord => '沒有成功同步時間的記錄'; + + @override + String get cachedSyncError => '同步失敗;快取資料可能已過時。'; + + @override + String get calendarRemotePending => 'Google 可見性變更待處理'; + + @override + String get calendarRemoteFailed => + 'Google 顯示設定變更失敗。目前顯示 Google 上次確認的狀態。BusyMax 仍遵循待處理的變更。請重試或捨棄該變更。'; + + @override + String discardCalendarSettingsConfirmation(String calendar, String settings) { + return '要捨棄「$calendar」的待處理變更嗎?\n\n以下設定將還原為先前的值:\n$settings\n\nBusyMax 中的行事曆選擇將會保留。不會向 Google 傳送任何變更。'; + } + + @override + String get sharedCalendarUnavailable => '無法存取;正在顯示快取資料。請重試同步或從行事曆清單移除。'; + + @override + String get invalidCollectionColor => '請輸入以 # 開頭的六位或八位十六進位色彩。'; + + @override + String get invalidCollectionOrder => '請輸入整數。'; + + @override + String get invalidCollectionTimezone => '請輸入包含 VTIMEZONE 和 TZID 的有效行事曆時區文件。'; + + @override + String get invalidCollectionValue => '儲存前請修正此值。'; + + @override + String get accountAlreadyConnected => '此帳戶已連線。請使用該帳戶的「重新連線」操作。'; + + @override + String get nextcloudRemovalAuthorizationDescription => + 'BusyMax 也會嘗試撤銷此帳戶的 Nextcloud 應用程式密碼。即使撤銷失敗,本機移除仍可成功。'; } diff --git a/lib/src/android/android_authorization.dart b/lib/src/android/android_authorization.dart index e7eb4557..57c6b613 100644 --- a/lib/src/android/android_authorization.dart +++ b/lib/src/android/android_authorization.dart @@ -7,6 +7,7 @@ import '../db/app_database.dart' hide AuthorizationCommit; import 'android_account_gate.dart'; import 'dart:async'; +import 'dart:convert'; import '../core/http/bounded_http.dart'; @@ -115,6 +116,9 @@ final class AndroidAuthorizationBroker final http.Client _httpClient; final SecretStore _secretStore; final Map _lastAccessTokens = {}; + final _microsoftRefreshRequests = {}; + final _microsoftRefreshedRequests = >{}; + var _microsoftRefreshSequence = 0; @override Future get activeAccountId => _secretStore.readActiveAccountId(); @@ -752,18 +756,21 @@ final class AndroidAuthorizationBroker String id, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }) async { final attempt = _attempts.begin(_nowUtc, cancellation); NativeOAuthCredential? current; const scope = googleContactsReadScope; try { current = await attempt.wait(nativeCredential(id, BusyProvider.google)); - if (await _persistExistingContacts( - id, - current!, - attempt, - persistContacts, - )) { + if (!forceAuthorization && + await _parentConnected(id) && + await _persistExistingContacts( + id, + current!, + attempt, + persistContacts, + )) { attempt.committed(); return; } @@ -790,11 +797,13 @@ final class AndroidAuthorizationBroker String id, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }) => _optionalNativeConsent( id, _microsoftContactsReadNativeScope, cancellation, persistContacts: persistContacts, + forceAuthorization: forceAuthorization, ); @override @@ -803,16 +812,19 @@ final class AndroidAuthorizationBroker String accountId, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }) => switch (provider) { BusyProvider.google => authorizeGoogleContacts( accountId, cancellation: cancellation, persistContacts: persistContacts, + forceAuthorization: forceAuthorization, ), BusyProvider.microsoft => authorizeMicrosoftContacts( accountId, cancellation: cancellation, persistContacts: persistContacts, + forceAuthorization: forceAuthorization, ), _ => throw StateError('$provider does not use OAuth contacts access.'), }; @@ -822,6 +834,7 @@ final class AndroidAuthorizationBroker String scope, AuthorizationCancellation? cancellation, { Future Function()? persistContacts, + bool forceAuthorization = false, }) async { final attempt = _attempts.begin(_nowUtc, cancellation); NativeOAuthCredential? current; @@ -829,7 +842,9 @@ final class AndroidAuthorizationBroker current = await attempt.wait( nativeCredential(id, BusyProvider.microsoft), ); - if (scope == _microsoftContactsReadNativeScope && + if (!forceAuthorization && + await _parentConnected(id) && + scope == _microsoftContactsReadNativeScope && await _persistExistingContacts( id, current!, @@ -857,6 +872,12 @@ final class AndroidAuthorizationBroker } } + Future _parentConnected(String id) async => + (await (persistence!.database.select( + persistence!.database.accounts, + )..where((r) => r.id.equals(id))).getSingleOrNull())?.authState == + 'signed_in'; + Future _writeNativeGrants(String id, OAuthTokenSet tokens) async => await (persistence!.database.update( persistence!.database.accounts, @@ -992,7 +1013,7 @@ final class AndroidAuthorizationBroker ), ), BusyProvider.microsoft => await _boundedSilent( - _platform.authorizeMicrosoftSilently( + _microsoftSilentToken( accountId: accountId, scopes: _microsoftNativeScopes, clientId: microsoft?.registration.clientId, @@ -1054,7 +1075,7 @@ final class AndroidAuthorizationBroker : await nativeCredential(accountId, BusyProvider.microsoft) as MicrosoftAndroidCredential; final native = await _boundedSilent( - _platform.authorizeMicrosoftSilently( + _microsoftSilentToken( accountId: accountId, scopes: [ ..._microsoftNativeScopes, @@ -1154,7 +1175,7 @@ final class AndroidAuthorizationBroker as MicrosoftAndroidCredential; const requiredNativeScope = _microsoftContactsReadNativeScope; final native = await _boundedSilent( - _platform.authorizeMicrosoftSilently( + _microsoftSilentToken( accountId: accountId, scopes: [..._microsoftNativeScopes, requiredNativeScope], clientId: current?.registration.clientId, @@ -1191,7 +1212,7 @@ final class AndroidAuthorizationBroker : await nativeCredential(accountId, BusyProvider.microsoft) as MicrosoftAndroidCredential; final native = await _boundedSilent( - _platform.authorizeMicrosoftSilently( + _microsoftSilentToken( accountId: accountId, scopes: [..._microsoftNativeScopes, _microsoftSharedNativeScope], clientId: current?.registration.clientId, @@ -1223,7 +1244,7 @@ final class AndroidAuthorizationBroker : await nativeCredential(accountId, BusyProvider.microsoft) as MicrosoftAndroidCredential; final native = await _boundedSilent( - _platform.authorizeMicrosoftSilently( + _microsoftSilentToken( accountId: accountId, scopes: [..._microsoftNativeScopes, _microsoftCategoryNativeScope], clientId: current?.registration.clientId, @@ -1247,12 +1268,61 @@ final class AndroidAuthorizationBroker } } + Future _microsoftSilentToken({ + required String accountId, + required List scopes, + String? clientId, + String? authorityTenant, + String? nativeAccountId, + String? authority, + String? claims, + }) async { + final refreshRequest = _microsoftRefreshRequests[accountId]; + final requestKey = jsonEncode([ + clientId, + authorityTenant, + nativeAccountId, + authority, + claims, + [...scopes]..sort(), + ]); + final forceRefresh = + refreshRequest != null && + !(_microsoftRefreshedRequests[accountId]?.contains(requestKey) ?? + false); + final token = await _platform.authorizeMicrosoftSilently( + accountId: accountId, + scopes: scopes, + clientId: clientId, + authorityTenant: authorityTenant, + nativeAccountId: nativeAccountId, + authority: authority, + claims: claims, + forceRefresh: forceRefresh, + ); + if (forceRefresh && + _microsoftRefreshRequests[accountId] == refreshRequest) { + (_microsoftRefreshedRequests[accountId] ??= {}).add(requestKey); + } + return token; + } + @override Future recoverUnauthorized( BusyProvider provider, String accountId, ) async { final rejected = _lastAccessTokens.remove(accountId); + if (provider == BusyProvider.microsoft) { + // Refresh the retry's exact native request, including its optional scopes + // and claims. Acquiring an ordinary token here could leave that request + // using a different rejected cache entry. + _microsoftRefreshRequests[accountId] = ++_microsoftRefreshSequence; + // Scope and claims variants can occupy different MSAL cache entries. + // An intervening acquisition must not acknowledge another variant. + _microsoftRefreshedRequests.remove(accountId); + return; + } if (provider == BusyProvider.google && rejected != null) { await _platform.clearRejectedToken(rejected); } @@ -1356,6 +1426,8 @@ final class AndroidAuthorizationBroker final target = accountId ?? await activeAccountId; if (target != null) { _lastAccessTokens.remove(target); + _microsoftRefreshRequests.remove(target); + _microsoftRefreshedRequests.remove(target); await _secretStore.deleteCredential(target); if (target.startsWith('google:')) { await _platform.removeAuthorization( diff --git a/lib/src/android/android_notifications.dart b/lib/src/android/android_notifications.dart index 1c9de7b1..6b19b98d 100644 --- a/lib/src/android/android_notifications.dart +++ b/lib/src/android/android_notifications.dart @@ -712,7 +712,7 @@ final class AndroidNotificationService if (task.status == 'completed' || task.status == 'cancelled') { return false; } - final due = taskDueAsLocal(task); + final due = taskDeadlineAsLocal(task); return due != null && due.year == now.year && due.month == now.month && diff --git a/lib/src/android/presentation/android_contacts_settings.dart b/lib/src/android/presentation/android_contacts_settings.dart index ccf7c214..5ef17449 100644 --- a/lib/src/android/presentation/android_contacts_settings.dart +++ b/lib/src/android/presentation/android_contacts_settings.dart @@ -1,4 +1,5 @@ import '../../l10n/l10n.dart'; +import '../../l10n/contacts_error_description.dart'; import 'dart:async'; @@ -32,11 +33,7 @@ class _AndroidContactsSettingsState await action(); } on Object catch (error) { if (mounted) { - setState( - () => _error = error is ContactsException - ? error.code - : context.l10n.operationFailed, - ); + setState(() => _error = localizedContactsError(context.l10n, error)); } } finally { if (mounted) setState(() => _pending = false); @@ -119,7 +116,11 @@ class _AndroidContactsSettingsState ].contains(p.provider), )) ...[ Text('${context.l10n.contactsTitle}: ${parent.displayLabel}'), - Text(context.l10n.contactsReadPermissionDescription), + Text( + parent.provider == BusyProvider.nextcloud + ? context.l10n.contactsNextcloudReuse + : context.l10n.contactsReadPermissionDescription, + ), _action( context.l10n.contactsEnableSuggestions, () => parent.provider == BusyProvider.nextcloud @@ -138,12 +139,18 @@ class _AndroidContactsSettingsState Text( '${account.displayName} · ${context.l10n.contactsReadOnlyUse} · ${account.enabled ? context.l10n.contactsSuggestionsEnabled : context.l10n.off}', ), - if (account.errorCode != null) Text(account.errorCode!), + if (account.errorCode != null) + Text(localizedContactsStatus(context.l10n, account.errorCode!)), Wrap( spacing: 8, children: [ + if (account.enabled) + _action( + context.l10n.contactsRetrySync, + () => controller.synchronizeAccount(account.id), + ), _action( - context.l10n.connectAccountAction, + context.l10n.reconnectAccount, () => _reconnect(account), ), _action( diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index 946d2ea7..a7af2308 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -1,3 +1,5 @@ +import '../../features/recurrence/presentation/recurrence_summary.dart'; + import 'package:busymax/src/core/auth/authorization_attempt.dart'; import 'dart:async'; @@ -3527,6 +3529,56 @@ class _AndroidEventEditorState extends ConsumerState { } setState(() => _saving = true); try { + final seriesTiming = await ref + .read(calendarRepositoryProvider) + .prepareSeriesTimingChange(draft); + if (!mounted) return; + if (seriesTiming?.requiresConfirmation == true) { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.repeat), + content: Text( + context.l10n.seriesPatternChangeConfirmation( + recurrenceRuleSummary( + dialogContext, + seriesTiming!.previewRule( + following: + draft.recurringMutationScope == + RecurringEventMutationScope.thisAndFuture, + original: true, + ), + timeZone: seriesTiming.timeZone, + ), + recurrenceRuleSummary( + dialogContext, + seriesTiming.previewRule( + following: + draft.recurringMutationScope == + RecurringEventMutationScope.thisAndFuture, + ), + timeZone: seriesTiming.timeZone, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.save), + ), + ], + ), + ); + if (confirmed != true) { + if (mounted) setState(() => _saving = false); + return; + } + draft = draft.copyWith(seriesTimingApproval: seriesTiming!.signature); + } if ((_reminderMinutes ?? -2) >= 0 || androidGooglePopupReminderRows(_editedGoogleReminders).isNotEmpty || (_editedNextcloudReminders?.isNotEmpty ?? false)) { diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart index 20d48f42..bc3e0de3 100644 --- a/lib/src/android/presentation/android_settings_screen.dart +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -22,6 +22,7 @@ import '../../app/app_bootstrap.dart'; import '../../calendar_providers/calendar_colors.dart'; import '../../core/auth/oauth_models.dart'; import '../../dav/dav_errors.dart'; +import '../../dav/http/dav_http_transport.dart'; import '../../dav/mutation/dav_conflict_repository.dart'; import '../../dav/nextcloud/nextcloud_sharing_service.dart'; import '../../dav/nextcloud/nextcloud_trash_service.dart'; @@ -142,7 +143,7 @@ class _AndroidSettingsScreenState extends ConsumerState { account.needsReconnect) PopupMenuItem( value: 'reconnect', - child: Text(context.l10n.connectAccountAction), + child: Text(context.l10n.reconnectAccount), ), PopupMenuItem( value: 'remove', @@ -706,7 +707,11 @@ class _AndroidSettingsScreenState extends ConsumerState { final state = account.isSignedIn ? context.l10n.signedInAccount : account.authState; - final synced = account.lastSuccessfulSyncAtUtc; + final synced = + account.provider == BusyProvider.nextcloud || + account.provider == BusyProvider.appleICloud + ? account.lastSuccessfulSyncAtUtc + : null; return synced == null ? '${account.provider.displayName} · $state' : '${account.provider.displayName} · $state\n${context.l10n.davLastSuccessfulSync(DateFormat.yMd().add_jm().format(synced.toLocal()))}'; @@ -723,6 +728,8 @@ class _AndroidSettingsScreenState extends ConsumerState { } final ownedCancellation = AuthorizationCancellation(); _authorizationCancellation = ownedCancellation; + final davCancellation = DavCancellationToken(); + unawaited(ownedCancellation.signal.then((_) => davCancellation.cancel())); try { final localNetworkDenied = context.l10n.nextcloudOperationDenied; String? email; @@ -801,24 +808,35 @@ class _AndroidSettingsScreenState extends ConsumerState { ? (await service.connectAppleICloud( email: email!, appSpecificPassword: password!, + cancellationToken: davCancellation, )).accountId : (await service.replaceAppleAppSpecificPassword( accountId: reconnecting.id, appSpecificPassword: password!, + cancellationToken: davCancellation, )).accountId; case BusyProvider.nextcloud: final service = ref.read(davAccountOnboardingServiceProvider); accountId = reconnecting == null - ? (await service.connectNextcloud(enteredServer: server!)) - .accountId + ? (await service.connectNextcloud( + enteredServer: server!, + cancellationToken: davCancellation, + )).accountId : (await service.reconnectNextcloud( accountId: reconnecting.id, enteredServer: server!, + cancellationToken: davCancellation, )).accountId; case BusyProvider.webCal: return; } - if (accountId != null) unawaited(_syncAccount(accountId)); + if (accountId != null) { + ownedCancellation.committed(); + if (mounted && + identical(_authorizationCancellation, ownedCancellation)) { + unawaited(_syncAccount(accountId)); + } + } } on Object catch (error) { if (error is OAuthException && error.code == 'OAuthSignInCancelled') { return; @@ -826,7 +844,10 @@ class _AndroidSettingsScreenState extends ConsumerState { if (error is DavException && error.kind == DavErrorKind.cancelled) { return; } - if (mounted) _message(localizedAuthorizationError(context.l10n, error)); + if (mounted && + identical(_authorizationCancellation, ownedCancellation)) { + _message(localizedAuthorizationError(context.l10n, error)); + } } finally { if (mounted && identical(_authorizationCancellation, ownedCancellation)) { @@ -861,6 +882,8 @@ class _AndroidSettingsScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Text(context.l10n.removeAccountConfirmation), + if (account.provider == BusyProvider.nextcloud) + Text(context.l10n.nextcloudRemovalAuthorizationDescription), if (account.provider == BusyProvider.google) CheckboxListTile( contentPadding: EdgeInsets.zero, @@ -888,21 +911,30 @@ class _AndroidSettingsScreenState extends ConsumerState { if (confirmed != true) return; setState(() => _removing.add(account.id)); try { - await ref.read(crossEngineAccountGateProvider).run(account.id, () async { - if (account.provider == BusyProvider.appleICloud || - account.provider == BusyProvider.nextcloud) { - await ref - .read(davAccountOnboardingServiceProvider) - .removeAccount(account.id); - } else { - await ref - .read(authRepositoryProvider) - .removeAccount( - accountId: account.id, - revokeAuthorization: revoke, - ); + if (account.provider == BusyProvider.appleICloud || + account.provider == BusyProvider.nextcloud) { + // DAV onboarding owns this account gate, including contact preparation. + final result = await ref + .read(davAccountOnboardingServiceProvider) + .removeAccount(account.id); + if (mounted && + result.remoteRevocationAttempted && + !result.remoteRevocationSucceeded) { + _message(l10n.nextcloudAccountRemovedRevokeFailed); } - }); + } else { + await ref + .read(crossEngineAccountGateProvider) + .run( + account.id, + () => ref + .read(authRepositoryProvider) + .removeAccount( + accountId: account.id, + revokeAuthorization: revoke, + ), + ); + } await ref.read(notificationReconcilerProvider).reconcile(); } on Object catch (error) { _message(localizedAccountRemovalError(l10n, error)); diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 784e0db7..fbf6d418 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -1,5 +1,6 @@ import '../core/http/request_dispatch_exception.dart'; import '../features/sync/domain_sync_schedule.dart'; +import '../features/sync/account_sync_activity.dart'; import '../core/auth/authorization_persistence.dart'; import '../core/auth/registration_staging.dart'; import '../core/auth/oauth_registration.dart'; @@ -151,67 +152,91 @@ final baseHttpClientProvider = Provider((ref) { }); final nextcloudLoginFlowV2Provider = Provider((ref) { - return NextcloudLoginFlowV2(client: ref.watch(baseHttpClientProvider)); + final flow = NextcloudLoginFlowV2(client: ref.watch(baseHttpClientProvider)); + ref.onDispose(flow.cancel); + return flow; }); -final davAccountOnboardingServiceProvider = - Provider((ref) { - final client = ref.watch(baseHttpClientProvider); - return DavAccountOnboardingService( - database: ref.watch(databaseProvider), - secretStore: ref.watch(secretStoreProvider), - accountsRepository: ref.watch(accountsRepositoryProvider), - nextcloudLoginFlow: ref.watch(nextcloudLoginFlowV2Provider), - discover: - ({ - required accountId, - required provider, - required accountAuthority, - required credential, - cancellationToken, - }) { - final profile = davProviderProfile( - provider, - nextcloudServer: provider == BusyProvider.nextcloud - ? accountAuthority - : null, - ); - final transport = DavHttpTransport( - client: client, - profile: profile, - accountAuthority: accountAuthority, - ); - return DavDiscoveryService( - transport: transport, - profile: profile, - accountAuthority: accountAuthority, - accountId: accountId, - credential: credential, - ).discover( - correlationId: const Uuid().v4(), - cancellationToken: cancellationToken, - ); - }, - nextcloudCredentialRevoker: - ({required accountId, required credential}) { - final profile = davProviderProfile( - BusyProvider.nextcloud, - nextcloudServer: credential.canonicalServer, - ); - return NextcloudAppPasswordRevoker( - transport: DavHttpTransport( - client: client, - profile: profile, - accountAuthority: credential.canonicalServer, - ), - ).revoke( - accountId: accountId, - credential: credential, - correlationId: const Uuid().v4(), - ); - }, +final Provider +davAccountOnboardingServiceProvider = Provider(( + ref, +) { + final client = ref.watch(baseHttpClientProvider); + return DavAccountOnboardingService( + database: ref.watch(databaseProvider), + secretStore: ref.watch(secretStoreProvider), + authorizationPersistence: ref.watch(authorizationPersistenceProvider), + accountGate: ref.watch(crossEngineAccountGateProvider), + syncCoordinator: ref.watch(accountSyncCoordinatorProvider), + onAccountRemovalStarting: (id) => ref + .read(busyMaxContactsControllerProvider) + .beginLinkedAccountRemoval(id), + onAccountRemoving: (id) => ref + .read(busyMaxContactsControllerProvider) + .prepareLinkedAccountRemoval(id), + persistLinkedAccountRemoval: (id) => ref + .read(busyMaxContactsControllerProvider) + .persistLinkedAccountRemoval(id), + onAccountRemovalFailed: (id) => ref + .read(busyMaxContactsControllerProvider) + .restoreLinkedAccountAfterFailedRemoval(id), + onAccountRemoved: () => ref + .read(busyMaxContactsControllerProvider) + .linkedAccountRemovalCommitted(), + onAccountRemovalFinished: (id) => ref + .read(busyMaxContactsControllerProvider) + .finishLinkedAccountRemoval(id), + accountsRepository: ref.watch(accountsRepositoryProvider), + nextcloudLoginFlow: ref.watch(nextcloudLoginFlowV2Provider), + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) { + final profile = davProviderProfile( + provider, + nextcloudServer: provider == BusyProvider.nextcloud + ? accountAuthority + : null, + ); + final transport = DavHttpTransport( + client: client, + profile: profile, + accountAuthority: accountAuthority, + ); + return DavDiscoveryService( + transport: transport, + profile: profile, + accountAuthority: accountAuthority, + accountId: accountId, + credential: credential, + ).discover( + correlationId: const Uuid().v4(), + cancellationToken: cancellationToken, + ); + }, + nextcloudCredentialRevoker: ({required accountId, required credential}) { + final profile = davProviderProfile( + BusyProvider.nextcloud, + nextcloudServer: credential.canonicalServer, ); - }); + return NextcloudAppPasswordRevoker( + transport: DavHttpTransport( + client: client, + profile: profile, + accountAuthority: credential.canonicalServer, + ), + ).revoke( + accountId: accountId, + credential: credential, + correlationId: const Uuid().v4(), + ); + }, + ); +}); final retryingHttpClientProvider = Provider((ref) { return RetryingHttpClient(inner: ref.watch(baseHttpClientProvider)); @@ -370,8 +395,8 @@ final busyMaxContactsControllerProvider = Provider(( deleteDavSecret: (accountId) => storage.delete(key: key(accountId)), readLinkedDavCredential: (accountId) async { final credential = await ref - .read(secretStoreProvider) - .readCredential(accountId); + .read(authorizationPersistenceProvider) + .readCurrentCredential(accountId); return credential is NextcloudSecretRecord ? BusyMaxLinkedDavCredential( server: credential.canonicalServer, @@ -380,6 +405,28 @@ final busyMaxContactsControllerProvider = Provider(( ) : null; }, + reconnectLinkedDavAccount: (id, cancellation) async { + final account = await ref + .read(accountsRepositoryProvider) + .accountById(id); + if (account == null) { + throw const ContactsException( + ContactsFailure.authentication, + 'linked-parent-removed', + ); + } + final token = DavCancellationToken(); + if (cancellation.isCancelled) token.cancel(); + unawaited(cancellation.signal.then((_) => token.cancel())); + await ref + .read(davAccountOnboardingServiceProvider) + .reconnectNextcloud( + accountId: id, + enteredServer: account.authority, + cancellationToken: token, + ); + cancellation.committed(); + }, launchBrowser: (uri) => launchUrl(uri, mode: LaunchMode.externalApplication), ); @@ -1134,10 +1181,68 @@ final calendarMonthRetrieverProvider = Provider((ref) { ); }); +final accountSyncActivityProvider = ChangeNotifierProvider( + (ref) => AccountSyncActivity(), +); + +final accountDomainSyncTimesProvider = + StreamProvider.family, String>((ref, id) { + final db = ref.watch(databaseProvider); + return (db.select( + db.domainSyncSchedules, + )..where((r) => r.accountId.equals(id))).watch().map( + (rows) => { + for (final row in rows) + if (DateTime.tryParse(row.lastSuccessfulPullUtc ?? '') + case final time?) + row.domain: time.toUtc(), + }, + ); + }); + +final davAccountDiscoveryProvider = + StreamProvider.family((ref, id) { + final db = ref.watch(databaseProvider); + return (db.select( + db.davAccountServices, + )..where((row) => row.accountId.equals(id))).watchSingleOrNull(); + }); + +final independentContactServerProvider = FutureProvider.family( + (ref, id) => + ref.watch(busyMaxContactsControllerProvider).independentServer(id), +); + +final independentContactAccountIdsProvider = FutureProvider>((ref) { + ref.watch(busyMaxContactAccountsProvider); + return ref.watch(busyMaxContactsStoreProvider).independentAccountIds(); +}); + +final calendarVisibilityOperationProvider = + StreamProvider.family((ref, id) { + final db = ref.watch(databaseProvider); + return (db.select(db.pendingOps)..where( + (r) => + r.calendarSourceId.equals(id) & + r.operationType.equals('calendar.patch'), + )) + .watch() + .map( + (rows) => rows.where((r) { + try { + return (jsonDecode(r.requestJson) as Map).containsKey('hidden'); + } on Object { + return false; + } + }).firstOrNull, + ); + }); + final accountSyncOperationsProvider = Provider((ref) { final accountsRepository = ref.watch(accountsRepositoryProvider); final connectivity = ref.watch(networkConnectivityMonitorProvider); final syncCoordinator = ref.watch(accountSyncCoordinatorProvider); + final activity = ref.watch(accountSyncActivityProvider.notifier); Future providerForAccount(String accountId) async { final account = await accountsRepository.accountById(accountId); @@ -1149,56 +1254,68 @@ final accountSyncOperationsProvider = Provider((ref) { final routing = RoutingAccountSyncOperations( providerForAccount: providerForAccount, - syncDav: (accountId, {required full}) => syncCoordinator.trackTaskImport( + syncDav: (accountId, {required full}) => activity.run( accountId, - () => ref - .read(davAccountSyncEngineFactoryProvider)(accountId) - .synchronize(full: full), + 'dav', + () => syncCoordinator.trackTaskImport( + accountId, + () => ref + .read(davAccountSyncEngineFactoryProvider)(accountId) + .synchronize(full: full), + ), ), syncWebCal: (accountId, {required full}) => ref .read(webCalSubscriptionServiceProvider) .refreshAccount(accountId, force: full), - syncTasksRest: (accountId, {required full}) async { - final provider = await providerForAccount(accountId); - final engine = ref.read(syncEngineForAccountFactoryProvider)( - accountId, - provider, - ); - await DomainSyncPolicy(ref.read(databaseProvider)).run( - accountId, - SyncDomain.tasks, - full: full, - deferred: engine.dispatchPendingWrites, - maintainCached: engine.maintainCachedReminders, - pull: () => syncCoordinator.trackTaskImport(accountId, () async { - if (full) { - await engine.fullSync(); - } else { - await engine.incrementalSync(); - } - }, changedOnFailure: () => engine.remoteImportWroteData), - ); + syncTasksRest: (accountId, {required full}) { + var pulled = false; + return activity.run(accountId, 'tasks', () async { + final provider = await providerForAccount(accountId); + final engine = ref.read(syncEngineForAccountFactoryProvider)( + accountId, + provider, + ); + await DomainSyncPolicy(ref.read(databaseProvider)).run( + accountId, + SyncDomain.tasks, + full: full, + deferred: engine.dispatchPendingWrites, + maintainCached: engine.maintainCachedReminders, + pull: () => syncCoordinator.trackTaskImport(accountId, () async { + if (full) { + await engine.fullSync(); + } else { + await engine.incrementalSync(); + } + pulled = true; + }, changedOnFailure: () => engine.remoteImportWroteData), + ); + }, clearFailureOnSuccess: () => pulled); }, - syncCalendarRest: (accountId, {required full}) async { - final provider = await providerForAccount(accountId); - final engine = ref.read(calendarSyncEngineForAccountFactoryProvider)( - accountId, - provider, - ); - await DomainSyncPolicy(ref.read(databaseProvider)).run( - accountId, - SyncDomain.calendar, - full: full, - deferred: engine.dispatchPendingWrites, - maintainCached: engine.maintainCachedReminders, - pull: () async { - if (full) { - await engine.fullSync(); - } else { - await engine.incrementalSync(); - } - }, - ); + syncCalendarRest: (accountId, {required full}) { + var pulled = false; + return activity.run(accountId, 'calendar', () async { + final provider = await providerForAccount(accountId); + final engine = ref.read(calendarSyncEngineForAccountFactoryProvider)( + accountId, + provider, + ); + await DomainSyncPolicy(ref.read(databaseProvider)).run( + accountId, + SyncDomain.calendar, + full: full, + deferred: engine.dispatchPendingWrites, + maintainCached: engine.maintainCachedReminders, + pull: () async { + if (full) { + await engine.fullSync(); + } else { + await engine.incrementalSync(); + } + pulled = true; + }, + ); + }, clearFailureOnSuccess: () => pulled); }, ); return CrossEngineCoordinatedAccountSyncOperations( diff --git a/lib/src/app/busymax_design.dart b/lib/src/app/busymax_design.dart index cd24e0e5..295f093c 100644 --- a/lib/src/app/busymax_design.dart +++ b/lib/src/app/busymax_design.dart @@ -1773,6 +1773,7 @@ class BusyMaxActionRow extends StatefulWidget { this.tooltip, this.destructive = false, this.autofocus = false, + this.focusNode, this.hoverColor, }) : assert(onTap == null || onActivated == null); @@ -1788,6 +1789,7 @@ class BusyMaxActionRow extends StatefulWidget { final String? tooltip; final bool destructive; final bool autofocus; + final FocusNode? focusNode; final Color? hoverColor; @override @@ -1848,6 +1850,7 @@ class _BusyMaxActionRowState extends State { trailing: widget.trailing, enabled: widget.enabled, autofocus: widget.autofocus, + focusNode: widget.focusNode, hoverColor: widget.hoverColor ?? busyMaxRowHoverColor(context), onTap: interactive ? _activate : null, ); @@ -4139,6 +4142,7 @@ class BusyMaxPromptDialog extends StatefulWidget { required this.actionLabel, this.initialValue, this.message, + this.obscureText = false, }); final String title; @@ -4146,6 +4150,7 @@ class BusyMaxPromptDialog extends StatefulWidget { final String actionLabel; final String? initialValue; final String? message; + final bool obscureText; @override State createState() => _BusyMaxPromptDialogState(); @@ -4209,6 +4214,9 @@ class _BusyMaxPromptDialogState extends State { YaruListTile.square( title: TextFormField( controller: _controller, + obscureText: widget.obscureText, + enableSuggestions: !widget.obscureText, + autocorrect: !widget.obscureText, autofocus: true, textInputAction: TextInputAction.done, decoration: busyMaxGroupedTextFieldDecoration( diff --git a/lib/src/app/busymax_dialogs.dart b/lib/src/app/busymax_dialogs.dart index 7eb37e06..fb145679 100644 --- a/lib/src/app/busymax_dialogs.dart +++ b/lib/src/app/busymax_dialogs.dart @@ -146,6 +146,7 @@ Future showBusyMaxTextPrompt( required String actionLabel, String? initialValue, String? message, + bool obscureText = false, Color? barrierColor, }) { return showBusyMaxModalDialog( @@ -158,6 +159,7 @@ Future showBusyMaxTextPrompt( actionLabel: actionLabel, initialValue: initialValue, message: message, + obscureText: obscureText, ), ); } diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index 0fe8ce08..29b7cbdf 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -34,6 +34,9 @@ const calendarEventOriginalStartKey = '_originalStart'; const calendarEventOriginalEndKey = '_originalEnd'; // Occurrence identity stays in _originalStart; each edit has its own interval. const calendarEventTimingBaselineKey = '_timingBaseline'; +const calendarEventSeriesTimingKey = '_seriesTiming'; +const calendarEventSeriesAppliedMasterKey = '_seriesAppliedMaster'; +const calendarEventSeriesCheckedMasterKey = '_seriesCheckedMaster'; const calendarEventDestinationCalendarIdKey = '_destinationCalendarId'; const calendarEventDestinationSourceIdKey = '_destinationSourceId'; const calendarEventCopyConfirmationRequiredKey = '_copyConfirmationRequired'; diff --git a/lib/src/calendar_providers/cloud_calendar_client.dart b/lib/src/calendar_providers/cloud_calendar_client.dart index 44c428a8..01cd6271 100644 --- a/lib/src/calendar_providers/cloud_calendar_client.dart +++ b/lib/src/calendar_providers/cloud_calendar_client.dart @@ -102,6 +102,37 @@ abstract interface class CompleteRecurringInstanceClient { }); } +/// Complete detached state and provider-resolved identities for series edits. +/// This does not enumerate ordinary instances of an unlimited recurrence. +abstract interface class SeriesExceptionClient { + Future> readSeriesExceptions({ + required String calendarId, + required CalendarEventDto master, + }); + + Future findSeriesInstance({ + required String calendarId, + required String recurringEventId, + required DateTime originalStart, + required bool allDay, + required String? timeZone, + }); + + Future restoreSeriesException({ + required String calendarId, + required CalendarEventDto instance, + required CalendarEventDto exception, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }); +} + +final class SeriesExceptionPreservationUnsupported implements Exception { + const SeriesExceptionPreservationUnsupported(this.message); + final String message; + @override + String toString() => message; +} + /// Provider-aware private-copy import. A calendar-file import must never fall /// back to ordinary event creation, which has different UID and invitation /// semantics. diff --git a/lib/src/contacts/busymax_contacts_controller.dart b/lib/src/contacts/busymax_contacts_controller.dart index f0a3d9aa..994adce1 100644 --- a/lib/src/contacts/busymax_contacts_controller.dart +++ b/lib/src/contacts/busymax_contacts_controller.dart @@ -13,10 +13,13 @@ import 'package:http/http.dart' as http; import '../core/auth/account_token_broker.dart'; import '../core/auth/authorization_attempt.dart'; +import '../core/auth/oauth_models.dart'; +import '../core/secrets/secret_store.dart'; import '../core/auth/microsoft_graph_scopes.dart'; import '../features/accounts/data/accounts_repository.dart'; import '../google_tasks/oauth/oauth_service.dart'; import '../providers/busy_provider.dart'; +import '../providers/account_authority.dart'; import 'busymax_contacts_store.dart'; import 'read_only_contacts_provider.dart'; @@ -78,6 +81,7 @@ final class BusyMaxContactsController { this.syncInterval = const Duration(minutes: 15), ContactsProvider Function(ContactAccount)? providerFactory, this.readAuthorizationGeneration, + this.reconnectLinkedDavAccount, }) : _providerFactory = providerFactory, directory = ContactsDirectory(store: store); @@ -95,6 +99,11 @@ final class BusyMaxContactsController { final ContactsDirectory directory; final ContactsProvider Function(ContactAccount)? _providerFactory; final Future Function(String accountId)? readAuthorizationGeneration; + final Future Function( + String accountId, + AuthorizationCancellation cancellation, + )? + reconnectLinkedDavAccount; final Map> _syncing = >{}; Timer? _timer; @@ -290,6 +299,11 @@ final class BusyMaxContactsController { await attempt.done.future; } + void cancelEnrollment(String id) { + _enrollments[id]?.cancel(); + _enrollments[linkedContactAccountId(id)]?.cancel(); + } + void _checkEnrollment(_ContactsEnrollment attempt) { if (_closed || attempt.cancelled) { throw const ContactsException( @@ -300,8 +314,9 @@ final class BusyMaxContactsController { } Future enableLinkedContacts( - String busyMaxAccountId, - ) => _enroll(linkedContactAccountId(busyMaxAccountId), (attempt) async { + String busyMaxAccountId, { + bool forceAuthorization = false, + }) => _enroll(linkedContactAccountId(busyMaxAccountId), (attempt) async { await start(); _checkEnrollment(attempt); final parent = await accounts.accountById(busyMaxAccountId); @@ -330,6 +345,7 @@ final class BusyMaxContactsController { parent.provider, busyMaxAccountId, cancellation: attempt.authorization, + forceAuthorization: forceAuthorization, persistContacts: () async { _checkEnrollment(attempt); final granted = await accounts.accountById(busyMaxAccountId); @@ -386,6 +402,8 @@ final class BusyMaxContactsController { ); }, ); + attempt.committed = true; + if (_closed) return; _checkEnrollment(attempt); store.contactConfigurationChanged(); if (previous?.enabled == true) await _detachForReplacement(contactId); @@ -416,48 +434,65 @@ final class BusyMaxContactsController { } } - Future enableLinkedNextcloudContacts(String busyMaxAccountId) => - _enroll(linkedContactAccountId(busyMaxAccountId), (attempt) async { - await start(); - _checkEnrollment(attempt); - final parent = await accounts.accountById(busyMaxAccountId); - if (parent == null || parent.provider != BusyProvider.nextcloud) { - throw StateError('The selected Nextcloud account is unavailable.'); - } - final credential = await readLinkedDavCredential(busyMaxAccountId); - if (credential == null) { - throw StateError('The selected Nextcloud credential is unavailable.'); - } - _checkEnrollment(attempt); - final current = await accounts.accountById(busyMaxAccountId); - if (current?.providerAccountId != parent.providerAccountId || - current?.provider != parent.provider) { - throw const ContactsException( - ContactsFailure.cancelled, - 'parent-account-changed', - ); - } - final contactId = linkedContactAccountId(busyMaxAccountId); - final previous = await store.read((tx) => tx.account(contactId)); - final contactAccount = ContactAccount( - id: contactId, - provider: ContactProviderKind.carddav, - displayName: parent.displayLabel, - subject: credential.username, - grantedScopes: previous?.grantedScopes ?? {'carddav:read'}, - generation: (previous?.generation ?? -1) + 1, - ); - await store.saveLinkedAccount( - account: contactAccount, - busyMaxAccountId: busyMaxAccountId, - reuseAuthorization: true, + Future enableLinkedNextcloudContacts( + String busyMaxAccountId, { + bool reconnect = false, + }) => _enroll(linkedContactAccountId(busyMaxAccountId), (attempt) async { + await start(); + _checkEnrollment(attempt); + if (reconnect) { + final recover = reconnectLinkedDavAccount; + if (recover == null) { + throw const ContactsException( + ContactsFailure.authentication, + 'reconnect-parent-account', ); - if (previous?.enabled == true) await _detachForReplacement(contactId); - _checkEnrollment(attempt); - await _bind(contactAccount); - _ensureScheduling(); - _launch(() => synchronizeAccount(contactId)); - }); + } + await recover(busyMaxAccountId, attempt.authorization); + attempt.committed = true; + if (_closed) return; + } + final parent = await accounts.accountById(busyMaxAccountId); + if (parent == null || parent.provider != BusyProvider.nextcloud) { + throw StateError('The selected Nextcloud account is unavailable.'); + } + final credential = await readLinkedDavCredential(busyMaxAccountId); + if (credential == null) { + throw StateError('The selected Nextcloud credential is unavailable.'); + } + _checkEnrollment(attempt); + final current = await accounts.accountById(busyMaxAccountId); + if (current?.providerAccountId != parent.providerAccountId || + current?.provider != parent.provider) { + throw const ContactsException( + ContactsFailure.cancelled, + 'parent-account-changed', + ); + } + final contactId = linkedContactAccountId(busyMaxAccountId); + final previous = await store.read((tx) => tx.account(contactId)); + final contactAccount = ContactAccount( + id: contactId, + provider: ContactProviderKind.carddav, + displayName: parent.displayLabel, + subject: credential.username, + grantedScopes: previous?.grantedScopes ?? {'carddav:read'}, + generation: (previous?.generation ?? -1) + 1, + ); + await store.saveLinkedAccount( + account: contactAccount, + busyMaxAccountId: busyMaxAccountId, + reuseAuthorization: true, + validate: () => _checkEnrollment(attempt), + ); + attempt.committed = true; + if (_closed) return; + if (previous?.enabled == true) await _detachForReplacement(contactId); + _checkEnrollment(attempt); + await _bind(contactAccount); + _ensureScheduling(); + _launch(() => synchronizeAccount(contactId)); + }); Future addCardDavContactsOnly({ required String id, @@ -520,6 +555,8 @@ final class BusyMaxContactsController { _checkEnrollment(attempt); await store.write((tx) => tx.putAccount(account)); durable = true; + attempt.committed = true; + if (_closed) return account; _checkEnrollment(attempt); await _bind(account); _ensureScheduling(); @@ -638,7 +675,12 @@ final class BusyMaxContactsController { await _setAccountError( id, captured?.generation, - error is ContactsException ? error.code : 'contacts-sync-failed', + error is ContactsException + ? error.code == 'contacts-removal-recovery-required' || + error.code.startsWith('oauth:') + ? error.code + : 'contacts:${error.kind.name}:${error.code}' + : 'contacts-sync-failed', ); } on Object catch (cleanup, cleanupStack) { _log.warning( @@ -709,7 +751,8 @@ final class BusyMaxContactsController { graph: GraphClient( httpClient: httpClient, tokenProvider: ({claims}) async => GraphAuthorization( - (await authorization.contactsAuthorizationHeader( + (await _contactsAuthorizationHeader( + authorization, BusyProvider.microsoft, parentId, claims: claims, @@ -725,9 +768,21 @@ final class BusyMaxContactsController { ); case ContactProviderKind.carddav: final parentId = await store.linkedBusyMaxAccount(account.id); + if (parentId == null && await store.hasAccountLink(account.id)) { + throw const ContactsException( + ContactsFailure.authentication, + 'linked-parent-removed', + ); + } final linked = parentId == null ? null : await readLinkedDavCredential(parentId); + if (parentId != null && linked == null) { + throw const ContactsException( + ContactsFailure.authentication, + 'linked-parent-authorization-unavailable', + ); + } final encoded = linked == null ? await readDavSecret(account.id) : null; if (linked == null && encoded == null) { throw StateError('CardDAV credential missing.'); @@ -737,7 +792,6 @@ final class BusyMaxContactsController { 'version': 1, 'server': linked!.server.toString(), 'username': linked.username, - 'password': linked.password, 'readOnly': true, 'nextcloud': true, } @@ -747,7 +801,18 @@ final class BusyMaxContactsController { } validateBinding?.call(); _bindProvider( - _davProvider(account, config), + _davProvider( + account, + config, + linkedCredential: parentId == null + ? null + : () => _currentLinkedDavCredential( + account, + parentId, + Uri.parse(config['server'] as String), + config['username'] as String, + ), + ), accountGeneration: account.generation, ); } @@ -771,8 +836,9 @@ final class BusyMaxContactsController { CardDavContactsProvider _davProvider( ContactAccount account, - Map config, - ) { + Map config, { + Future Function()? linkedCredential, + }) { final server = Uri.parse(config['server'] as String); _requireDavServer(server); final profile = config['nextcloud'] == true @@ -792,10 +858,12 @@ final class BusyMaxContactsController { authority: server, profile: profile, ), - credentialProvider: () async => DavBasicCredential( - username: config['username'] as String, - password: config['password'] as String, - ), + credentialProvider: + linkedCredential ?? + () async => DavBasicCredential( + username: config['username'] as String, + password: config['password'] as String, + ), ); return CardDavContactsProvider( accountId: account.id, @@ -805,6 +873,62 @@ final class BusyMaxContactsController { ); } + /// Linked contacts reuse the accepted parent credential for every request. + /// Never retain an app password across parent reconnects, or fall back to an + /// independent contact secret when a linked parent's authorization is gone. + Future _currentLinkedDavCredential( + ContactAccount account, + String parentId, + Uri server, + String username, + ) async { + final credential = await readLinkedDavCredential(parentId); + final current = await store.read((tx) => tx.account(account.id)); + if (_closed || + current?.enabled != true || + current?.generation != account.generation || + await store.linkedBusyMaxAccount(account.id) != parentId) { + throw const ContactsException( + ContactsFailure.cancelled, + 'stale-linked-contact-binding', + ); + } + final parent = await accounts.accountById(parentId); + final expectedAuthority = normalizeAccountAuthority( + BusyProvider.nextcloud, + authority: server.toString(), + ); + final expectedSubject = normalizeProviderAccountId( + BusyProvider.nextcloud, + account.subject, + ); + if (credential == null || + normalizeProviderAccountId(BusyProvider.nextcloud, username) != + expectedSubject || + parent?.provider != BusyProvider.nextcloud || + parent?.authority != expectedAuthority || + parent?.providerAccountId != expectedSubject || + normalizeAccountAuthority( + BusyProvider.nextcloud, + authority: credential.server.toString(), + ) != + expectedAuthority || + normalizeProviderAccountId( + BusyProvider.nextcloud, + credential.username, + ) != + expectedSubject) { + throw const ContactsException( + ContactsFailure.authentication, + 'linked-parent-authorization-unavailable', + ); + } + return DavBasicCredential( + username: credential.username, + password: credential.password, + ); + } + Future removeLinkedAccount(String busyMaxAccountId) async { final id = linkedContactAccountId(busyMaxAccountId); _linkedRemovals[id]?.recoverable = false; @@ -1010,125 +1134,146 @@ final class BusyMaxContactsController { return encoded != null && (jsonDecode(encoded) as Map)['nextcloud'] == true; } + Future independentServer(String id) async { + if (await store.hasAccountLink(id)) return null; + final encoded = await readDavSecret(id); + if (encoded == null) return null; + final server = Uri.parse((jsonDecode(encoded) as Map)['server'] as String); + _requireDavServer(server); + return Uri( + scheme: server.scheme, + host: server.host, + port: server.hasPort ? server.port : null, + path: server.path, + ); + } + Future reconnectContactsAccount(String id) async { final account = await store.read((tx) => tx.account(id)); if (account == null) throw StateError('Contacts account unavailable'); final parent = await store.linkedBusyMaxAccount(id); + if (parent == null && await store.hasAccountLink(id)) { + throw const ContactsException( + ContactsFailure.authentication, + 'linked-parent-removed', + ); + } if (parent != null) { if (account.provider == ContactProviderKind.carddav) { - await enableLinkedNextcloudContacts(parent); + await enableLinkedNextcloudContacts(parent, reconnect: true); } else { - await enableLinkedContacts(parent); + await enableLinkedContacts(parent, forceAuthorization: true); } return; } await reconnectDavContactsOnly(id); } - Future reconnectDavContactsOnly(String id, {String? password}) => - _enroll(id, (attempt) async { - await start(); + Future reconnectDavContactsOnly( + String id, { + String? password, + }) => _enroll(id, (attempt) async { + await start(); + _checkEnrollment(attempt); + final previous = await store.read((tx) => tx.account(id)); + final encoded = await readDavSecret(id); + if (previous == null || + previous.provider != ContactProviderKind.carddav || + encoded == null || + await store.hasAccountLink(id)) { + throw StateError('Independent DAV account unavailable'); + } + final config = (jsonDecode(encoded) as Map).cast(); + if (password != null) { + if (password.isEmpty) { + throw const ContactsException( + ContactsFailure.validation, + 'password-required', + ); + } + config['password'] = password; + } else if (config['nextcloud'] == true) { + final token = DavCancellationToken(); + final flow = NextcloudLoginFlowV2( + server: Uri.parse(config['server'] as String), + client: httpClient, + launchBrowser: launchBrowser, + ); + attempt.onCancel = () { + token.cancel(); + flow.close(); + }; + try { + final result = await flow.authenticate(token); _checkEnrollment(attempt); - final previous = await store.read((tx) => tx.account(id)); - final encoded = await readDavSecret(id); - if (previous == null || - previous.provider != ContactProviderKind.carddav || - encoded == null || - await store.linkedBusyMaxAccount(id) != null) { - throw StateError('Independent DAV account unavailable'); - } - final config = (jsonDecode(encoded) as Map).cast(); - if (password != null) { - if (password.isEmpty) { - throw const ContactsException( - ContactsFailure.validation, - 'password-required', - ); - } - config['password'] = password; - } else if (config['nextcloud'] == true) { - final token = DavCancellationToken(); - final flow = NextcloudLoginFlowV2( - server: Uri.parse(config['server'] as String), - client: httpClient, - launchBrowser: launchBrowser, + if (result.loginName != config['username'] || + normalizeNextcloudServerAuthority(result.server.toString()) != + normalizeNextcloudServerAuthority(config['server'] as String)) { + throw const ContactsException( + ContactsFailure.authentication, + 'wrong-contact-identity', ); - attempt.onCancel = () { - token.cancel(); - flow.close(); - }; - try { - final result = await flow.authenticate(token); - _checkEnrollment(attempt); - if (result.loginName != config['username'] || - result.server.origin != - Uri.parse(config['server'] as String).origin) { - throw const ContactsException( - ContactsFailure.authentication, - 'wrong-contact-identity', - ); - } - config['password'] = result.appPassword; - } finally { - flow.close(); - } } - final replacement = ContactAccount( - id: previous.id, - provider: previous.provider, - displayName: previous.displayName, - subject: previous.subject, - enabled: true, - generation: previous.generation + 1, - grantedScopes: previous.grantedScopes, + config['password'] = result.appPassword; + } finally { + flow.close(); + } + } + final replacement = ContactAccount( + id: previous.id, + provider: previous.provider, + displayName: previous.displayName, + subject: previous.subject, + enabled: true, + generation: previous.generation + 1, + grantedScopes: previous.grantedScopes, + ); + final provider = _davProvider(replacement, config); + final cancellation = ContactsCancellationToken(); + attempt.onCancel = cancellation.cancel; + try { + if ((await provider.discover(cancellation)).isEmpty) { + throw const ContactsException( + ContactsFailure.permission, + 'no-readable-address-books', ); - final provider = _davProvider(replacement, config); - final cancellation = ContactsCancellationToken(); - attempt.onCancel = cancellation.cancel; - try { - if ((await provider.discover(cancellation)).isEmpty) { - throw const ContactsException( - ContactsFailure.permission, - 'no-readable-address-books', - ); - } - } finally { - provider.close(); - } - _checkEnrollment(attempt); - final next = jsonEncode(config); - var durable = false; - try { - await writeDavSecret(id, next); - _checkEnrollment(attempt); - await store.write((tx) => tx.putAccount(replacement)); - durable = true; - await _detachForReplacement(id); - _checkEnrollment(attempt); - await _bind(replacement); - } on Object catch (error, stack) { - try { - if (!durable) { - await writeDavSecret(id, encoded); - } else { - await _detachBinding(id); - await store.write( - (tx) => tx.putAccount( - replacement.copy( - enabled: false, - errorCode: 'reconnect-required', - ), - ), - ); - } - } on Object catch (cleanup, cleanupStack) { - _log.warning('DAV reconnect cleanup failed', cleanup, cleanupStack); - } - Error.throwWithStackTrace(error, stack); + } + } finally { + provider.close(); + } + _checkEnrollment(attempt); + final next = jsonEncode(config); + var durable = false; + try { + await writeDavSecret(id, next); + _checkEnrollment(attempt); + await store.write((tx) => tx.putAccount(replacement)); + durable = true; + attempt.committed = true; + if (_closed) return; + await _detachForReplacement(id); + _checkEnrollment(attempt); + await _bind(replacement); + } on Object catch (error, stack) { + try { + if (!durable) { + await writeDavSecret(id, encoded); + } else { + await _detachBinding(id); + await store.write( + (tx) => tx.putAccount( + replacement.copy(enabled: false, errorCode: 'reconnect-required'), + ), + ); } - _ensureScheduling(); - await synchronizeAccount(id); - }); + } on Object catch (cleanup, cleanupStack) { + _log.warning('DAV reconnect cleanup failed', cleanup, cleanupStack); + } + Error.throwWithStackTrace(error, stack); + } + _ensureScheduling(); + await synchronizeAccount(id); + }); Future setSourceEnabled(String sourceKey, {required bool enabled}) { return store.setSourceEnabled(sourceKey, enabled: enabled); @@ -1195,8 +1340,10 @@ final class _ContactsEnrollment { final AuthorizationCancellation authorization = AuthorizationCancellation(); final Completer done = Zone.root.run(Completer.new); bool cancelled = false; + bool committed = false; void Function()? onCancel; void cancel() { + if (committed) return; cancelled = true; authorization.cancel(); onCancel?.call(); @@ -1216,8 +1363,11 @@ final class _BusyMaxGoogleContactsClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { - request.headers['authorization'] = await authorization - .contactsAuthorizationHeader(BusyProvider.google, accountId); + request.headers['authorization'] = await _contactsAuthorizationHeader( + authorization, + BusyProvider.google, + accountId, + ); return inner.send(request); } @@ -1225,3 +1375,53 @@ final class _BusyMaxGoogleContactsClient extends http.BaseClient { @override void close() {} } + +// Preserve app-owned authorization classifications at the provider boundary. +// The shared adapters otherwise intentionally reduce unknown token errors. +Future _contactsAuthorizationHeader( + ContactsAuthorizationBroker authorization, + BusyProvider provider, + String id, { + String? claims, +}) async { + try { + return await authorization.contactsAuthorizationHeader( + provider, + id, + claims: claims, + ); + } on OAuthException catch (error) { + _throwContactsAuthorizationFailure(provider, error.classification); + } on SecretStoreException { + _throwContactsAuthorizationFailure(provider, OAuthFailureKind.storage); + } +} + +Never _throwContactsAuthorizationFailure( + BusyProvider provider, + OAuthFailureKind kind, +) { + final failure = switch (kind) { + OAuthFailureKind.cancelled => ContactsFailure.cancelled, + OAuthFailureKind.permission => ContactsFailure.permission, + OAuthFailureKind.network || + OAuthFailureKind.timeout => ContactsFailure.offline, + OAuthFailureKind.throttled => ContactsFailure.throttled, + OAuthFailureKind.temporary || + OAuthFailureKind.authorizationCode || + OAuthFailureKind.alreadyConnected => ContactsFailure.server, + _ => ContactsFailure.authentication, + }; + final code = 'oauth:${kind.name}'; + if (provider == BusyProvider.microsoft) { + throw GraphException(switch (failure) { + ContactsFailure.cancelled => GraphFailure.cancelled, + ContactsFailure.permission => GraphFailure.permission, + ContactsFailure.offline => GraphFailure.connectivity, + ContactsFailure.throttled => GraphFailure.throttled, + ContactsFailure.server => GraphFailure.server, + _ => GraphFailure.authentication, + }, code); + } + throw ContactsException(failure, code); +} diff --git a/lib/src/contacts/busymax_contacts_store.dart b/lib/src/contacts/busymax_contacts_store.dart index b4f53005..b4f6b72b 100644 --- a/lib/src/contacts/busymax_contacts_store.dart +++ b/lib/src/contacts/busymax_contacts_store.dart @@ -110,14 +110,17 @@ final class BusyMaxContactsStore implements ContactsStore { required ContactAccount account, required String busyMaxAccountId, required bool reuseAuthorization, + void Function()? validate, }) => _track( - () => database.transaction( - () => persistLinkedAccount( + () => database.transaction(() async { + validate?.call(); + await persistLinkedAccount( account: account, busyMaxAccountId: busyMaxAccountId, reuseAuthorization: reuseAuthorization, - ), - ), + ); + validate?.call(); + }), notify: true, ); @@ -177,6 +180,27 @@ ON CONFLICT(contact_account_id) DO UPDATE SET return row?.readNullable('busymax_account_id'); }); + /// The link row is provenance even after its parent was deleted by an older + /// version (ON DELETE SET NULL). A null parent never makes it independent. + Future hasAccountLink(String id) => _track( + () async => + (await database + .customSelect( + 'SELECT contact_account_id FROM bm_contact_account_links WHERE contact_account_id = ?', + variables: [Variable(id)], + ) + .getSingleOrNull()) != + null, + ); + + Future> independentAccountIds() => _track( + () async => (await database.customSelect(''' +SELECT a.id FROM bm_contact_accounts a +LEFT JOIN bm_contact_account_links l ON l.contact_account_id = a.id +WHERE l.contact_account_id IS NULL +''').get()).map((row) => row.read('id')).toSet(), + ); + Future> linkedContactAccounts(String busyMaxAccountId) => _track( () async => (await database @@ -232,7 +256,10 @@ SELECT a.data AS account_data, s.source_key, JOIN bm_contact_sources s ON s.account_id = a.id LEFT JOIN bm_contact_account_links l ON l.contact_account_id = a.id LEFT JOIN bm_contact_source_preferences p ON p.source_key = s.source_key - WHERE (? IS NULL OR l.busymax_account_id IS NULL OR l.busymax_account_id = ?) + WHERE (l.contact_account_id IS NULL OR + (l.busymax_account_id IS NOT NULL AND + EXISTS (SELECT 1 FROM accounts parent WHERE parent.id = l.busymax_account_id))) + AND (? IS NULL OR l.contact_account_id IS NULL OR l.busymax_account_id = ?) ORDER BY a.id, s.source_key ''', variables: >[ diff --git a/lib/src/core/auth/account_token_broker.dart b/lib/src/core/auth/account_token_broker.dart index 6bcf47ca..78b67e55 100644 --- a/lib/src/core/auth/account_token_broker.dart +++ b/lib/src/core/auth/account_token_broker.dart @@ -34,6 +34,7 @@ abstract interface class ContactsAuthorizationBroker { String accountId, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }); Future contactsAuthorizationHeader( @@ -62,16 +63,19 @@ final class DesktopAccountTokenBroker String accountId, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }) => switch (provider) { BusyProvider.google => google.authorizeGoogleContacts( accountId, cancellation: cancellation, persistContacts: persistContacts, + forceAuthorization: forceAuthorization, ), BusyProvider.microsoft => microsoft.authorizeMicrosoftContacts( accountId, cancellation: cancellation, persistContacts: persistContacts, + forceAuthorization: forceAuthorization, ), _ => throw StateError('$provider does not use OAuth contacts access.'), }; diff --git a/lib/src/core/auth/authorization_persistence.dart b/lib/src/core/auth/authorization_persistence.dart index b2f53aec..4a1d2297 100644 --- a/lib/src/core/auth/authorization_persistence.dart +++ b/lib/src/core/auth/authorization_persistence.dart @@ -66,6 +66,19 @@ final class AuthorizationPersistence { 'Authorization recovery must complete before this account can be used.', ); } + if (record is NextcloudSecretRecord || record is AppleICloudSecretRecord) { + final journal = await (database.select( + database.authorizationCommits, + )..where((r) => r.accountId.equals(id))).getSingleOrNull(); + if (journal != null && + !AuthorizationRecoveryState.decode(journal.previousNativeBindingJson) + .committed) { + throw const SecretStoreException( + 'SecretStoreRecoveryRequired', + 'Credential recovery must complete before this account can be used.', + ); + } + } return record; } @@ -170,7 +183,9 @@ final class AuthorizationPersistence { } else { await secrets.deleteCredential(journal.accountId); } - if (restoreNativeBinding != null) { + if (!recovery.manageNativeBinding) { + // DAV credentials have no native OAuth binding. + } else if (restoreNativeBinding != null) { await restoreNativeBinding!( journal.accountId, recovery.previousNativeBinding, @@ -186,7 +201,7 @@ final class AuthorizationPersistence { final account = await (database.select( database.accounts, )..where((r) => r.id.equals(journal.accountId))).getSingleOrNull(); - if (account == null) { + if (account == null && recovery.manageNativeBinding) { await onRemovalCommitted?.call( journal.accountId, recovery.previousNativeBinding, @@ -204,6 +219,7 @@ final class AuthorizationPersistence { required SecretRecord candidate, required Future Function() persistAccount, bool requireExisting = false, + bool activateAccount = true, void Function()? validateCandidate, void Function()? onCommitted, }) => run(accountId, () async { @@ -222,6 +238,12 @@ final class AuthorizationPersistence { // Capture the latest accepted record, including refresh-token rotation. final previous = await secrets.readCredential(accountId); var credential = candidate; + final davCredential = + candidate is NextcloudSecretRecord || + candidate is AppleICloudSecretRecord; + final candidateGeneration = davCredential + ? expectedGeneration + 1 + : _generation(candidate); if (credential is BoundOAuthSecretRecord && !credential.tokenSet.canRefresh) { if (previous is BoundOAuthSecretRecord && @@ -243,7 +265,9 @@ final class AuthorizationPersistence { } } final previousActive = await secrets.readActiveAccountId(); - final nativeSnapshot = await readNativeBinding?.call(accountId); + final nativeSnapshot = davCredential + ? null + : await readNativeBinding?.call(accountId); if (previous != null) { await secrets.saveCredential(_backupKey(accountId), previous); } @@ -252,13 +276,14 @@ final class AuthorizationPersistence { .insertOnConflictUpdate( AuthorizationCommitsCompanion.insert( accountId: accountId, - generation: _generation(credential), + generation: candidateGeneration, hadCredential: previous != null, previousActiveAccountId: Value(previousActive), previousNativeBindingJson: Value( AuthorizationRecoveryState( committed: false, previousNativeBinding: nativeSnapshot, + manageNativeBinding: !davCredential, ).encode(), ), ), @@ -273,8 +298,11 @@ final class AuthorizationPersistence { 'Credential storage could not be verified.', ); } - await onCredentialWritten?.call(accountId, credential); - await secrets.setActiveAccountId(accountId); + validateCandidate?.call(); + if (!davCredential) { + await onCredentialWritten?.call(accountId, credential); + } + if (activateAccount) await secrets.setActiveAccountId(accountId); await database.transaction(() async { validateCandidate?.call(); await persistAccount(); @@ -283,18 +311,20 @@ final class AuthorizationPersistence { .insertOnConflictUpdate( AuthorizationGenerationsCompanion.insert( accountId: accountId, - generation: _generation(credential), - ), - ); - await database - .into(database.accountAuthorizations) - .insertOnConflictUpdate( - AccountAuthorizationsCompanion.insert( - accountId: accountId, - generation: _generation(credential), - summaryJson: jsonEncode(summaryJson(_summary(credential))), + generation: candidateGeneration, ), ); + if (!davCredential) { + await database + .into(database.accountAuthorizations) + .insertOnConflictUpdate( + AccountAuthorizationsCompanion.insert( + accountId: accountId, + generation: candidateGeneration, + summaryJson: jsonEncode(summaryJson(_summary(credential))), + ), + ); + } validateCandidate?.call(); await (database.update( database.authorizationCommits, @@ -304,6 +334,7 @@ final class AuthorizationPersistence { AuthorizationRecoveryState( committed: true, previousNativeBinding: nativeSnapshot, + manageNativeBinding: !davCredential, ).encode(), ), ), @@ -317,7 +348,9 @@ final class AuthorizationPersistence { } else { await secrets.deleteCredential(accountId); } - if (restoreNativeBinding != null) { + if (davCredential) { + // DAV has no native OAuth binding to restore. + } else if (restoreNativeBinding != null) { await restoreNativeBinding!(accountId, nativeSnapshot); } else { await onCredentialWritten?.call(accountId, previous); @@ -451,11 +484,14 @@ final class AuthorizationPersistence { Future removeCoherently( String id, Future Function() clearAuthorization, - Future Function() deleteAccount, - ) async { + Future Function() deleteAccount, { + bool manageNativeBinding = true, + }) async { final previous = await secrets.readCredential(id); final active = await secrets.readActiveAccountId(); - final nativeSnapshot = await readNativeBinding?.call(id); + final nativeSnapshot = manageNativeBinding + ? await readNativeBinding?.call(id) + : null; final nextGeneration = await generation(id) + 1; if (previous != null) { await secrets.saveCredential(_backupKey(id), previous); @@ -472,6 +508,7 @@ final class AuthorizationPersistence { AuthorizationRecoveryState( committed: false, previousNativeBinding: nativeSnapshot, + manageNativeBinding: manageNativeBinding, ).encode(), ), ), @@ -490,6 +527,7 @@ final class AuthorizationPersistence { AuthorizationRecoveryState( committed: true, previousNativeBinding: nativeSnapshot, + manageNativeBinding: manageNativeBinding, ).encode(), ), ), @@ -501,7 +539,9 @@ final class AuthorizationPersistence { } else { await secrets.deleteCredential(id); } - if (restoreNativeBinding != null) { + if (!manageNativeBinding) { + // Local DAV rollback needs only the secure record. + } else if (restoreNativeBinding != null) { await restoreNativeBinding!(id, nativeSnapshot); } else { await onCredentialWritten?.call(id, previous); @@ -512,7 +552,7 @@ final class AuthorizationPersistence { } // Failed native cleanup retains the journal for retry on restart. Account // deletion and generation invalidation are already durable. - if (onRemovalCommitted != null) { + if (manageNativeBinding && onRemovalCommitted != null) { await onRemovalCommitted!(id, nativeSnapshot); } else { await clearAuthorization(); @@ -562,12 +602,15 @@ final class AuthorizationRecoveryState { const AuthorizationRecoveryState({ required this.committed, this.previousNativeBinding, + this.manageNativeBinding = true, }); + final bool manageNativeBinding; final bool committed; final NativeBindingSnapshot? previousNativeBinding; String encode() => jsonEncode({ 'version': 1, 'committed': committed, + 'manageNativeBinding': manageNativeBinding, 'previousNativeBinding': previousNativeBinding?.toJson(), }); factory AuthorizationRecoveryState.decode(String? serialized) { @@ -579,6 +622,7 @@ final class AuthorizationRecoveryState { final binding = value['previousNativeBinding']; return AuthorizationRecoveryState( committed: value['committed'] as bool, + manageNativeBinding: value['manageNativeBinding'] as bool? ?? true, previousNativeBinding: binding == null ? null : NativeBindingSnapshot.fromJson(binding as Map), diff --git a/lib/src/core/auth/oauth_models.dart b/lib/src/core/auth/oauth_models.dart index fcadf7ef..ee7abf1e 100644 --- a/lib/src/core/auth/oauth_models.dart +++ b/lib/src/core/auth/oauth_models.dart @@ -154,6 +154,8 @@ class OAuthException implements Exception { 'OAuthSignInCancelled' => OAuthFailureKind.cancelled, 'OAuthStaleAuthorization' => OAuthFailureKind.stale, 'OAuthAccountAlreadyConnected' => OAuthFailureKind.alreadyConnected, + 'OAuthContactsConsentRequired' || + 'MicrosoftOAuthContactsConsentRequired' || 'OAuthMissingRequiredScope' || 'MicrosoftOAuthMissingRequiredScope' || 'OAuthMissingRefreshToken' || diff --git a/lib/src/core/time/stored_temporal_projection.dart b/lib/src/core/time/stored_temporal_projection.dart index 46bf209a..08d0cd35 100644 --- a/lib/src/core/time/stored_temporal_projection.dart +++ b/lib/src/core/time/stored_temporal_projection.dart @@ -79,6 +79,17 @@ DateTime? taskDueAsLocal(Task task) { return providerDateTimeAsLocal(task.dueUtc, null); } +/// Deadline eligibility differs from schedule/reminder navigation: CalDAV +/// stores DTSTART in dueUtc when no DUE exists, but that is not a deadline. +DateTime? taskDeadlineAsLocal(Task task) { + if (!isVisibleTaskProjection(task)) return null; + final metadata = _metadata(task.providerMetadataJson); + if (metadata?['transport'] == 'caldav' && metadata?['nativeDue'] is! Map) { + return null; + } + return taskDueAsLocal(task); +} + Map? _metadata(String? json) { if (json == null || json.isEmpty) return null; try { diff --git a/lib/src/dav/auth/dav_account_onboarding_service.dart b/lib/src/dav/auth/dav_account_onboarding_service.dart index 6c00e693..c24fa757 100644 --- a/lib/src/dav/auth/dav_account_onboarding_service.dart +++ b/lib/src/dav/auth/dav_account_onboarding_service.dart @@ -1,4 +1,10 @@ +import 'dart:async'; + import 'package:drift/drift.dart'; + +import '../../core/auth/authorization_persistence.dart'; +import '../../features/sync/account_sync_operations.dart'; + import 'package:uuid/uuid.dart'; import '../../core/secrets/secret_store.dart'; @@ -54,11 +60,23 @@ final class DavAccountOnboardingService { required DavOnboardingDiscovery discover, required NextcloudLoginFlowV2 nextcloudLoginFlow, AccountsRepository? accountsRepository, + AuthorizationPersistence? authorizationPersistence, + this.accountGate = const InProcessAccountGate(), + this.syncCoordinator, + this.onAccountRemovalStarting, + this.onAccountRemoving, + this.persistLinkedAccountRemoval, + this.onAccountRemovalFailed, + this.onAccountRemoved, + this.onAccountRemovalFinished, DavDiscoveryRepository? discoveryRepository, NextcloudCredentialRevoker? nextcloudCredentialRevoker, String Function()? idFactory, DateTime Function()? nowUtc, - }) : _database = database, + }) : _persistence = + authorizationPersistence ?? + AuthorizationPersistence(database: database, secrets: secretStore), + _database = database, _secretStore = secretStore, _discover = discover, _nextcloudLoginFlow = nextcloudLoginFlow, @@ -70,6 +88,24 @@ final class DavAccountOnboardingService { _idFactory = idFactory ?? const Uuid().v4, _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); + final AuthorizationPersistence _persistence; + final CrossEngineAccountGate accountGate; + final AccountSyncCoordinator? syncCoordinator; + final void Function(String)? onAccountRemovalStarting; + final Future Function(String)? onAccountRemoving; + final Future Function(String)? persistLinkedAccountRemoval; + final Future Function(String)? onAccountRemovalFailed; + final void Function()? onAccountRemoved; + final void Function(String)? onAccountRemovalFinished; + final Map> _removing = {}; + final Map> _accountWork = {}; + + Future _serialized(String id, Future Function() work) => + accountGate.run( + id, + () => syncCoordinator == null ? work() : syncCoordinator!.run(id, work), + ); + final AppDatabase _database; final SecretStore _secretStore; final DavOnboardingDiscovery _discover; @@ -110,7 +146,10 @@ final class DavAccountOnboardingService { required String enteredServer, DavCancellationToken? cancellationToken, }) async { - final login = await _nextcloudLoginFlow.start(enteredServer); + final login = await _nextcloudLoginFlow.start( + enteredServer, + cancellationToken: cancellationToken, + ); cancellationToken?.throwIfCancelled(); return _connectNextcloudLogin(login, cancellationToken: cancellationToken); } @@ -128,7 +167,11 @@ final class DavAccountOnboardingService { safeMessage: 'The Nextcloud account could not be reconnected.', ); } - final login = await _nextcloudLoginFlow.start(enteredServer); + final generation = await _persistence.generation(accountId); + final login = await _nextcloudLoginFlow.start( + enteredServer, + cancellationToken: cancellationToken, + ); cancellationToken?.throwIfCancelled(); final authority = normalizeAccountAuthority( BusyProvider.nextcloud, @@ -149,6 +192,8 @@ final class DavAccountOnboardingService { final result = await _connectNextcloudLogin( login, cancellationToken: cancellationToken, + expectedAccountId: accountId, + expectedGeneration: generation, ); if (result.accountId != accountId) { throw const DavException( @@ -163,6 +208,8 @@ final class DavAccountOnboardingService { Future _connectNextcloudLogin( NextcloudLoginFlowResult login, { DavCancellationToken? cancellationToken, + String? expectedAccountId, + int? expectedGeneration, }) { final credential = NextcloudSecretRecord( canonicalServer: login.canonicalServer, @@ -181,6 +228,8 @@ final class DavAccountOnboardingService { calendarsEnabled: true, tasksEnabled: true, displayName: credential.loginName, + expectedAccountId: expectedAccountId, + expectedGeneration: expectedGeneration, cancellationToken: cancellationToken, ); } @@ -207,89 +256,138 @@ final class DavAccountOnboardingService { username: previous.username, appSpecificPassword: appSpecificPassword, ); - final discovery = await _discover( - accountId: accountId, - provider: BusyProvider.appleICloud, - accountAuthority: Uri.parse(appleICloudAccountAuthority), - credential: DavBasicCredential( + return _connect( + provider: account.provider, + authority: Uri.parse(account.authority), + providerAccountId: account.providerAccountId, + credential: replacement, + basicCredential: DavBasicCredential( username: replacement.username, password: replacement.appSpecificPassword, ), + calendarsEnabled: true, + tasksEnabled: false, + displayName: account.displayName ?? replacement.username, + email: account.email, + expectedAccountId: accountId, + expectedGeneration: await _persistence.generation(accountId), + activateAccount: false, cancellationToken: cancellationToken, ); - _validateDiscovery( - discovery, - accountId: accountId, - provider: BusyProvider.appleICloud, - ); - await _replaceCredentialVerified(accountId, replacement, previous); - try { - await _database.transaction(() async { - await _accountsRepository.upsertSignedInAccount( - id: accountId, - provider: BusyProvider.appleICloud, - providerAccountId: account.providerAccountId, - authority: appleICloudAccountAuthority, - credentialKind: CredentialKind.appleAppSpecificPassword, - displayName: account.displayName, - email: account.email, - grantedScopes: '', - calendarsEnabled: true, - tasksEnabled: false, - ); - await _discoveryRepository.commitSuccessfulInventory(discovery); - await _requeueAuthenticationBlockedOperations(accountId); - }); - return DavAccountConnectionResult( - accountId: accountId, - discovery: discovery, - ); - } on Object { - await _secretStore.saveCredential(accountId, previous); - rethrow; + } + + Future removeAccount(String accountId) { + final running = _removing[accountId]; + if (running != null) return running; + for (final token in _accountWork[accountId] ?? {}) { + token.cancel(); } + onAccountRemovalStarting?.call(accountId); + late final Future operation; + operation = + _serialized(accountId, () async { + try { + return await _persistence.runRemoval(accountId, (snapshot) async { + final account = await _accountsRepository.accountById(accountId); + final credential = snapshot.credential; + // The journal restores the secret and selection if SQLite deletion fails. + // Revoke only after local commit so a failed removal keeps a usable account. + await _persistence.removeCoherently( + accountId, + () async {}, + () async { + await persistLinkedAccountRemoval?.call(accountId); + await _accountsRepository.deleteAccount(accountId); + }, + manageNativeBinding: false, + ); + onAccountRemoved?.call(); + var attempted = false, succeeded = false; + String? failureCode; + if (account?.provider == BusyProvider.nextcloud && + credential is NextcloudSecretRecord && + _nextcloudCredentialRevoker != null) { + attempted = true; + try { + await _nextcloudCredentialRevoker( + accountId: accountId, + credential: credential, + ); + succeeded = true; + } on DavException catch (error) { + failureCode = error.code; + } on Object { + failureCode = 'NextcloudAppPasswordRevocationFailed'; + } + } + return DavAccountRemovalResult( + remoteRevocationAttempted: attempted, + remoteRevocationSucceeded: succeeded, + remoteFailureCode: failureCode, + ); + }, prepare: () async => onAccountRemoving?.call(accountId)); + } on Object catch (error, stack) { + // Preparation itself may have detached a worker before failing. + try { + final canRestore = await _persistence.run( + accountId, + () => _persistence.removalRecoveryAvailable(accountId), + ); + // Retain the account gate and removal receipt while restoring, + // but release authorization first: binding contacts reads the + // verified credential through that same non-reentrant boundary. + // The contacts lifecycle also checks identity and generation. + if (canRestore) await onAccountRemovalFailed?.call(accountId); + } on Object { + /* retain original failure */ + } + Error.throwWithStackTrace(error, stack); + } + }).whenComplete(() { + _removing.remove(accountId); + onAccountRemovalFinished?.call(accountId); + }); + _removing[accountId] = operation; + return operation; } - Future removeAccount(String accountId) async { + /// Revalidate the saved credential without changing identity or registration. + /// Discovery must succeed before blocked accounts or queued writes are enabled. + Future revalidateAccount( + String accountId, { + DavCancellationToken? cancellationToken, + }) async { final account = await _accountsRepository.accountById(accountId); - if (account == null) { - return const DavAccountRemovalResult( - remoteRevocationAttempted: false, - remoteRevocationSucceeded: false, - remoteFailureCode: null, + final generation = await _persistence.generation(accountId); + final secret = await _persistence.readCurrentCredential(accountId); + if (account == null || + secret is! NextcloudSecretRecord || + account.provider != BusyProvider.nextcloud || + secret.canonicalServer.toString() != account.authority || + secret.loginName != account.providerAccountId) { + throw const DavException( + kind: DavErrorKind.authentication, + code: 'DavCredentialsRevoked', + safeMessage: 'Reconnect this account to restore its credential.', ); } - final credential = await _secretStore.readCredential(accountId); - var attempted = false; - var succeeded = false; - String? failureCode; - if (account.provider == BusyProvider.nextcloud && - credential is NextcloudSecretRecord && - _nextcloudCredentialRevoker != null) { - attempted = true; - try { - await _nextcloudCredentialRevoker( - accountId: accountId, - credential: credential, - ); - succeeded = true; - } on DavException catch (error) { - failureCode = error.code; - } on Object { - failureCode = 'NextcloudAppPasswordRevocationFailed'; - } - } - - // Local removal is deliberately independent of remote revocation success. - await _secretStore.deleteCredential(accountId); - if (await _secretStore.readActiveAccountId() == accountId) { - await _secretStore.clearActiveAccount(); - } - await _accountsRepository.deleteAccount(accountId); - return DavAccountRemovalResult( - remoteRevocationAttempted: attempted, - remoteRevocationSucceeded: succeeded, - remoteFailureCode: failureCode, + return _connect( + provider: account.provider, + authority: secret.canonicalServer, + providerAccountId: account.providerAccountId, + credential: secret, + basicCredential: DavBasicCredential( + username: secret.loginName, + password: secret.appPassword, + ), + calendarsEnabled: account.calendarsEnabled, + tasksEnabled: account.tasksEnabled, + displayName: account.displayName ?? secret.loginName, + email: account.email, + cancellationToken: cancellationToken, + expectedAccountId: accountId, + expectedGeneration: generation, + activateAccount: false, ); } @@ -303,6 +401,9 @@ final class DavAccountOnboardingService { required bool tasksEnabled, required String displayName, String? email, + String? expectedAccountId, + int? expectedGeneration, + bool activateAccount = true, DavCancellationToken? cancellationToken, }) async { final normalizedAuthority = normalizeAccountAuthority( @@ -313,6 +414,8 @@ final class DavAccountOnboardingService { provider, providerAccountId, ); + await _persistence.recover(); + cancellationToken?.throwIfCancelled(); final existing = await (_database.select(_database.accounts)..where( (row) => @@ -321,79 +424,114 @@ final class DavAccountOnboardingService { row.providerAccountId.equals(normalizedProviderAccountId), )) .getSingleOrNull(); + if (provider == BusyProvider.nextcloud && + expectedAccountId == null && + existing != null) { + throw const DavException( + kind: DavErrorKind.conflict, + code: 'DavAccountAlreadyConnected', + safeMessage: + 'This account is already connected. Use its Reconnect action.', + ); + } final accountId = existing?.id ?? '${provider.storageValue}:${_idFactory()}'; - final previousCredential = existing == null - ? null - : await _secretStore.readCredential(accountId); - - // No durable credential or connected account state is written until the - // supplied credential has completed principal/home/collection discovery. - final discovery = await _discover( - accountId: accountId, - provider: provider, - accountAuthority: Uri.parse(normalizedAuthority), - credential: basicCredential, - cancellationToken: cancellationToken, + if (expectedAccountId != null && existing?.id != expectedAccountId || + _removing.containsKey(accountId)) { + throw const DavException( + kind: DavErrorKind.cancelled, + code: 'DavAccountChanged', + safeMessage: 'The account changed during connection.', + ); + } + final generation = + expectedGeneration ?? await _persistence.generation(accountId); + final token = DavCancellationToken(); + final subscription = cancellationToken?.cancellations.listen( + (_) => token.cancel(), ); - _validateDiscovery(discovery, accountId: accountId, provider: provider); - await _replaceCredentialVerified(accountId, credential, previousCredential); + if (cancellationToken?.isCancelled == true) token.cancel(); + (_accountWork[accountId] ??= {}).add(token); try { - await _database.transaction(() async { - await _accountsRepository.upsertSignedInAccount( - id: accountId, + token.throwIfCancelled(); + final discovery = await _waitForDiscovery( + _discover( + accountId: accountId, provider: provider, - providerAccountId: normalizedProviderAccountId, - authority: normalizedAuthority, - credentialKind: credential.kind, - displayName: displayName, - email: email, - grantedScopes: '', - calendarsEnabled: calendarsEnabled, - tasksEnabled: tasksEnabled, + accountAuthority: Uri.parse(normalizedAuthority), + credential: basicCredential, + cancellationToken: token, + ), + token, + ); + _validateDiscovery(discovery, accountId: accountId, provider: provider); + var beganCommit = false; + final committing = _serialized(accountId, () { + beganCommit = true; + return _persistence.commit( + accountId: accountId, + expectedGeneration: generation, + candidate: credential, + requireExisting: existing != null, + activateAccount: activateAccount, + validateCandidate: token.throwIfCancelled, + persistAccount: () async { + await _accountsRepository.upsertSignedInAccount( + id: accountId, + provider: provider, + providerAccountId: normalizedProviderAccountId, + authority: normalizedAuthority, + credentialKind: credential.kind, + displayName: displayName, + email: email, + grantedScopes: '', + calendarsEnabled: calendarsEnabled, + tasksEnabled: tasksEnabled, + ); + await _discoveryRepository.commitSuccessfulInventory(discovery); + await _requeueAuthenticationBlockedOperations(accountId); + }, ); - await _discoveryRepository.commitSuccessfulInventory(discovery); - await _requeueAuthenticationBlockedOperations(accountId); }); - await _secretStore.setActiveAccountId(accountId); + await Future.any([ + committing, + token.whenCancelled.then((_) async { + // Queued work has no durable side effects. Let its caller finish so + // parent removal can drain contacts without waiting on its own gate. + if (!beganCommit) token.throwIfCancelled(); + // Once secure persistence starts, wait for its verified rollback or + // commit. Returning early could let a late secret overwrite recovery. + await committing; + }), + ]); + // A late cancellation cannot undo the accepted SQLite/credential commit. return DavAccountConnectionResult( accountId: accountId, discovery: discovery, ); - } on Object { - if (previousCredential != null) { - await _secretStore.saveCredential(accountId, previousCredential); - } else { - await _secretStore.deleteCredential(accountId); - await _accountsRepository.deleteAccount(accountId); + } finally { + await subscription?.cancel(); + _accountWork[accountId]?.remove(token); + if (_accountWork[accountId]?.isEmpty == true) { + _accountWork.remove(accountId); } - rethrow; } } - Future _replaceCredentialVerified( - String accountId, - SecretRecord replacement, - SecretRecord? previous, - ) async { - await _secretStore.saveCredential(accountId, replacement); - try { - final readBack = await _secretStore.readCredential(accountId); - if (!_sameCredential(readBack, replacement)) { - throw const SecretStoreException( - 'SecretStoreWriteVerificationFailed', - 'The credential could not be verified after secure storage.', - ); - } - } on Object { - if (previous != null) { - await _secretStore.saveCredential(accountId, previous); - } else { - await _secretStore.deleteCredential(accountId); - } - rethrow; - } - } + Future _waitForDiscovery( + Future discovery, + DavCancellationToken token, + ) => + Future.any([ + discovery, + token.whenCancelled.then((_) { + token.throwIfCancelled(); + throw StateError('Unreachable cancellation'); + }), + ]).then((result) { + token.throwIfCancelled(); + return result; + }); Future _requeueAuthenticationBlockedOperations(String accountId) async { await (_database.update(_database.pendingOps)..where( @@ -433,34 +571,3 @@ void _validateDiscovery( ); } } - -bool _sameCredential(SecretRecord? left, SecretRecord right) => - switch ((left, right)) { - ( - AppleICloudSecretRecord( - username: final leftUser, - appSpecificPassword: final leftPassword, - ), - AppleICloudSecretRecord( - username: final rightUser, - appSpecificPassword: final rightPassword, - ), - ) => - leftUser == rightUser && leftPassword == rightPassword, - ( - NextcloudSecretRecord( - canonicalServer: final leftServer, - loginName: final leftUser, - appPassword: final leftPassword, - ), - NextcloudSecretRecord( - canonicalServer: final rightServer, - loginName: final rightUser, - appPassword: final rightPassword, - ), - ) => - leftServer == rightServer && - leftUser == rightUser && - leftPassword == rightPassword, - _ => false, - }; diff --git a/lib/src/dav/auth/nextcloud_login_flow_v2.dart b/lib/src/dav/auth/nextcloud_login_flow_v2.dart index 234ce64b..cd889295 100644 --- a/lib/src/dav/auth/nextcloud_login_flow_v2.dart +++ b/lib/src/dav/auth/nextcloud_login_flow_v2.dart @@ -74,7 +74,10 @@ final class NextcloudLoginFlowV2 { Future? _activeOperation; DavCancellationToken? _activeCancellation; - Future start(String enteredServer) { + Future start( + String enteredServer, { + DavCancellationToken? cancellationToken, + }) { if (_activeOperation != null) { throw const DavException( kind: DavErrorKind.conflict, @@ -83,8 +86,16 @@ final class NextcloudLoginFlowV2 { ); } final cancellation = DavCancellationToken(); + cancellationToken?.throwIfCancelled(); + final subscription = cancellationToken?.cancellations.listen( + (_) => cancellation.cancel(), + ); _activeCancellation = cancellation; - final operation = _run(enteredServer, cancellation); + late final Future operation; + operation = _run(enteredServer, cancellation).whenComplete(() async { + await subscription?.cancel(); + _clear(operation); + }); _activeOperation = operation; unawaited( operation.then( @@ -137,7 +148,7 @@ final class NextcloudLoginFlowV2 { allowQuery: true, ); cancellation.throwIfCancelled(); - final launched = await _browserLauncher(loginUri); + final launched = await _wait(_browserLauncher(loginUri), cancellation); if (!launched) { throw const DavException( kind: DavErrorKind.protocol, @@ -162,7 +173,7 @@ final class NextcloudLoginFlowV2 { cancellation: cancellation, ); if (pollResponse.statusCode == HttpStatus.notFound) { - await _delay(pollInterval); + await _wait(_delay(pollInterval), cancellation); continue; } if (pollResponse.statusCode != HttpStatus.ok) { @@ -176,6 +187,7 @@ final class NextcloudLoginFlowV2 { ); } final completed = _jsonObject(pollResponse.bodyBytes); + cancellation.throwIfCancelled(); final normalizedAuthority = normalizeNextcloudServerAuthority( _requiredJsonString(completed, 'server'), ); @@ -205,9 +217,14 @@ final class NextcloudLoginFlowV2 { !_withinInstallationPath(current.path, trustedBase.path)) { throw _redirectError('NextcloudLoginRedirectRejected'); } - final request = http.Request('POST', current) - ..followRedirects = false - ..headers['accept'] = 'application/json'; + final request = + http.AbortableRequest( + 'POST', + current, + abortTrigger: cancellation.whenCancelled, + ) + ..followRedirects = false + ..headers['accept'] = 'application/json'; if (body != null) { request.headers['content-type'] = 'application/x-www-form-urlencoded; charset=utf-8'; @@ -215,7 +232,10 @@ final class NextcloudLoginFlowV2 { } http.StreamedResponse streamed; try { - streamed = await _client.send(request).timeout(responseTimeout); + streamed = await _wait( + _client.send(request).timeout(responseTimeout), + cancellation, + ); } on TimeoutException { throw const DavException( kind: DavErrorKind.timeout, @@ -228,14 +248,20 @@ final class NextcloudLoginFlowV2 { code: 'DavTlsFailure', safeMessage: 'The Nextcloud TLS connection could not be verified.', ); + } on DavException { + rethrow; } on Object { + cancellation.throwIfCancelled(); throw const DavException( kind: DavErrorKind.network, code: 'DavTransientNetwork', safeMessage: 'The Nextcloud login server could not be reached.', ); } - final bytes = await _readBody(streamed, cancellation); + final bytes = await _wait( + _readBody(streamed, cancellation), + cancellation, + ); if (!_isRedirect(streamed.statusCode)) { return _FlowResponse( statusCode: streamed.statusCode, @@ -258,6 +284,20 @@ final class NextcloudLoginFlowV2 { throw StateError('Unreachable Nextcloud redirect state.'); } + Future _wait(Future work, DavCancellationToken cancellation) { + cancellation.throwIfCancelled(); + return Future.any([ + work, + cancellation.whenCancelled.then((_) { + cancellation.throwIfCancelled(); + throw StateError('Unreachable cancellation'); + }), + ]).then((value) { + cancellation.throwIfCancelled(); + return value; + }); + } + Future _readBody( http.StreamedResponse response, DavCancellationToken cancellation, diff --git a/lib/src/dav/ical/ical_recurrence.dart b/lib/src/dav/ical/ical_recurrence.dart index dd83e4e2..5dbcf120 100644 --- a/lib/src/dav/ical/ical_recurrence.dart +++ b/lib/src/dav/ical/ical_recurrence.dart @@ -102,6 +102,33 @@ final class IcalRecurrenceExpander { final IcalRecurrenceLimits limits; + /// Candidates in one rule period, before DTSTART/COUNT/UNTIL truncation. + /// Series edits use this to map an existing recurrence position without + /// confusing a detached occurrence's effective date with its identity. + List rulePeriodCandidates({ + required String recurrenceRule, + required DateTime anchor, + required int period, + }) { + if (period < 0 || period >= limits.maximumPeriods) { + throw ArgumentError.value(period, 'period'); + } + return _RecurrenceRule.parse( + recurrenceRule, + anchor: IcalTemporalValue( + rawValue: '', + kind: IcalTemporalKind.floatingDateTime, + localValue: anchor, + timeZoneId: null, + ), + maximumValues: limits.maximumRuleValues, + ).candidatesForPeriod( + anchor, + period, + maximumCandidates: limits.maximumCandidatesPerPeriod, + ); + } + /// Returns the next RRULE occurrence after [current]. /// /// Nextcloud Tasks advances a recurring VTODO from its current DTSTART or @@ -197,6 +224,23 @@ final class IcalRecurrenceExpander { ..sort((left, right) => _compareRecurrenceIds(left, right)); final masterDuration = _componentDuration(master, timeZoneResolver); + final recurrenceDates = [ + for (final property in master.documentComponent.propertiesNamed('RDATE')) + ..._parseRecurrenceDates(property, anchor, timeZoneResolver), + ]; + final periodDurations = {}; + for (final date in recurrenceDates) { + final duration = date.duration; + if (duration == null) continue; + final key = _temporalIdentity(date.start); + final previous = periodDurations[key]; + if (previous != null && + (previous.calendarDays != duration.calendarDays || + previous.exact != duration.exact)) { + throw _invalidRule(); + } + periodDurations[key] = duration; + } final candidateBounds = _candidateProjectionBounds( rangeStart, rangeEnd, @@ -213,6 +257,7 @@ final class IcalRecurrenceExpander { void retainIfRelevant(IcalTemporalValue value) { final key = _temporalIdentity(value); if (excludedStartKeys.contains(key)) return; + final duration = periodDurations[key] ?? masterDuration; final exception = overrides[key]; final inheritedOverride = exception == null ? _rangeOverrideFor(value, rangeOverrides) @@ -226,13 +271,13 @@ final class IcalRecurrenceExpander { ? _occurrenceEnd( exception, effectiveStart, - masterDuration, + duration, timeZoneResolver, ) : _rangeOccurrenceEnd( inheritedOverride, effectiveStart, - masterDuration, + duration, timeZoneResolver, ); if (_overlaps( @@ -266,10 +311,8 @@ final class IcalRecurrenceExpander { retainIfRelevant, ); } - for (final property in master.documentComponent.propertiesNamed('RDATE')) { - for (final value in _parseDateList(property, anchor)) { - retainIfRelevant(value); - } + for (final date in recurrenceDates) { + retainIfRelevant(date.start); } for (final entry in overrides.entries) { final exception = entry.value; @@ -278,7 +321,7 @@ final class IcalRecurrenceExpander { final effectiveEnd = _occurrenceEnd( exception, effectiveStart, - masterDuration, + periodDurations[entry.key] ?? masterDuration, timeZoneResolver, ); if (!excludedStartKeys.contains(entry.key) && @@ -301,6 +344,7 @@ final class IcalRecurrenceExpander { final result = []; for (final entry in starts.entries) { final originalStart = entry.value; + final duration = periodDurations[entry.key] ?? masterDuration; final exception = overrides[entry.key]; final inheritedOverride = exception == null ? _rangeOverrideFor(originalStart, rangeOverrides) @@ -314,13 +358,13 @@ final class IcalRecurrenceExpander { ? _occurrenceEnd( exception, effectiveStart, - masterDuration, + duration, timeZoneResolver, ) : _rangeOccurrenceEnd( inheritedOverride, effectiveStart, - masterDuration, + duration, timeZoneResolver, ); if (!_overlaps( @@ -630,7 +674,37 @@ final class _RecurrenceRule { IcalTemporalValue anchor, IcalTimeZoneResolver timeZoneResolver, ) { - final wall = _periodAnchor(anchor.localValue, period); + final periodAnchor = _periodAnchor(anchor.localValue, period); + // Selectors can expand earlier than DTSTART's day or clock fields within + // a period. Use the lower bound of that candidate domain, not DTSTART + // shifted into the period (e.g. February 1, not February 26). + final wall = switch (frequency) { + _Frequency.secondly => periodAnchor, + _Frequency.minutely => _wall( + periodAnchor.year, + periodAnchor.month, + periodAnchor.day, + periodAnchor.hour, + periodAnchor.minute, + ), + _Frequency.hourly => _wall( + periodAnchor.year, + periodAnchor.month, + periodAnchor.day, + periodAnchor.hour, + ), + _Frequency.daily => _wall( + periodAnchor.year, + periodAnchor.month, + periodAnchor.day, + ), + _Frequency.weekly => _startOfWeek( + _wall(periodAnchor.year, periodAnchor.month, periodAnchor.day), + weekStart, + ), + _Frequency.monthly => _wall(periodAnchor.year, periodAnchor.month), + _Frequency.yearly => _wall(periodAnchor.year), + }; return icalTemporalToUtc( _withWallValue(anchor, wall), resolver: timeZoneResolver, @@ -758,7 +832,11 @@ final class _RecurrenceRule { if (_matchesWeekNumber(date)) dates.add(date); } } else { - final months = byMonth.isEmpty ? [start.month] : byMonth; + final months = byMonth.isNotEmpty + ? byMonth + : byMonthDay.isNotEmpty + ? List.generate(12, (index) => index + 1) + : [start.month]; for (final month in months) { for (final day in _monthDays(year, month, start.day)) { dates.add(_wall(year, month, day)); @@ -897,6 +975,45 @@ List _parseDateList( return result; } +List<({IcalTemporalValue start, _OccurrenceDuration? duration})> +_parseRecurrenceDates( + IcalProperty property, + IcalTemporalValue anchor, + IcalTimeZoneResolver resolver, +) { + final dates = <({IcalTemporalValue start, _OccurrenceDuration? duration})>[]; + IcalTemporalValue temporal(String raw) => + _parseDateList((property.deepCopy()..rawValue = raw), anchor).single; + for (final entry in property.rawValue.split(',')) { + final parts = entry.trim().split('/'); + final start = temporal(parts.first); + if (parts.length == 1) { + dates.add((start: start, duration: null)); + continue; + } + if (parts.length != 2 || start.isDate) throw _invalidRule(); + final isDuration = RegExp(r'^[+-]?P').hasMatch(parts[1]); + final duration = isDuration + ? _OccurrenceDuration.fromIcal(parseIcalDuration(parts[1])!) + : _OccurrenceDuration.exact( + icalTemporalToUtc( + temporal(parts[1]), + resolver: resolver, + ).difference(icalTemporalToUtc(start, resolver: resolver)), + ); + final end = _applyDuration(start, duration, resolver); + if (end == null || + !icalTemporalToUtc( + end, + resolver: resolver, + ).isAfter(icalTemporalToUtc(start, resolver: resolver))) { + throw _invalidRule(); + } + dates.add((start: start, duration: duration)); + } + return dates; +} + List _parametersForPrototype(IcalTemporalValue prototype) => switch (prototype.kind) { IcalTemporalKind.date => const [ @@ -1160,6 +1277,16 @@ IcalTemporalValue? _applyDuration( return _withWallValue(value, value.localValue.add(duration.exact)); } +/// Applies civil weeks/days before elapsed hours/minutes/seconds, using the +/// resource's own timezone rules, as required by RFC 5545 section 3.3.6. +IcalTemporalValue addIcalDuration( + IcalTemporalValue start, + IcalDuration duration, { + required IcalTimeZoneResolver resolver, +}) => + _applyDuration(start, _OccurrenceDuration.fromIcal(duration), resolver) ?? + start; + DateTime _wallDateTime(DateTime value) => DateTime.utc( value.year, value.month, diff --git a/lib/src/dav/mutation/dav_projection_mutations.dart b/lib/src/dav/mutation/dav_projection_mutations.dart index 37bed62b..50d18966 100644 --- a/lib/src/dav/mutation/dav_projection_mutations.dart +++ b/lib/src/dav/mutation/dav_projection_mutations.dart @@ -9,6 +9,8 @@ import 'dav_conditional_mutation_service.dart'; import 'dav_mutation_patch.dart'; import '../../features/calendar/presentation/event_editor_draft.dart'; import '../nextcloud/nextcloud_scheduling_mutations.dart'; +import '../../core/time/provider_date_time.dart'; +import '../../features/recurrence/domain/series_timing_change.dart'; /// Edits only URI ATTACH properties on one concrete VEVENT. Replacing the /// component with a deep copy retains untouched binary ATTACH properties, @@ -315,8 +317,11 @@ DavMutationPatch? buildDavEventUpdatePatch({ } if (timingOnly) { operations.removeWhere( - (operation) => - !{'DTSTART', 'DTEND', 'DURATION'}.contains(operation.propertyName), + (operation) => !{ + 'DTSTART', 'DTEND', 'DURATION', + // A series timing proposal can reanchor its existing selectors. + if (input.recurrenceChanged) ...['RRULE', 'EXRULE', 'RDATE', 'EXDATE'], + }.contains(operation.propertyName), ); } if (operations.isEmpty) return null; @@ -340,6 +345,153 @@ DavMutationPatch? buildDavEventUpdatePatch({ ); } +/// A series timing edit changes recurrence positions, not the actual schedule +/// authored on a detached exception. Rekey all exceptions in the same PUT as +/// the master, retaining their complete components (including cancellations). +DavMutationPatch reanchorDavSeriesExceptions({ + required DavMutationPatch patch, + required String baselineRawIcs, + required SeriesTimingChange change, + bool preserveDuration = false, +}) => DavMutationPatch( + target: patch.target, + scope: patch.scope, + operations: [ + ...patch.operations.where( + (operation) => + !preserveDuration || + !{'DTEND', 'DURATION'}.contains(operation.propertyName), + ), + ...davSeriesTimingExceptionOperations( + baselineRawIcs: baselineRawIcs, + uid: patch.target.uid, + change: change, + ), + ], +); + +/// Builds and validates the complete identity mapping before the editor closes +/// or any resource mutation is queued. +List davSeriesTimingExceptionOperations({ + required String baselineRawIcs, + required String uid, + required SeriesTimingChange change, +}) { + final semantic = IcalSemanticDocument.parse(baselineRawIcs); + final master = semantic.components.singleWhere( + (component) => component.uid == uid && component.recurrenceId == null, + ); + if (change.masterStart == change.originalMasterStart && + change.timeZone == change.originalTimeZone && + change.allDay == master.start!.isDate && + (!change.rule.isSupported || + change.rule.toRrule() == change.originalRule.toRrule())) { + return const []; + } + final operations = []; + final resolver = IcalTimeZoneResolver.fromDocument(semantic); + DateTime asCivil(IcalTemporalValue temporal) { + if (temporal.isDate || temporal.kind == IcalTemporalKind.floatingDateTime) { + return temporal.localValue; + } + final instant = resolver.toUtc(temporal); + final zone = master.start!.timeZoneId; + return zone == null ? instant : resolver.fromUtc(instant, zone); + } + + final exclusions = master.documentComponent + .propertiesNamed('EXDATE') + .toList(); + if (exclusions.isNotEmpty) { + operations.add( + DavPatchOperation.replaceRepeatedRaw('EXDATE', [ + for (final exclusion in exclusions) + for (final mapped in change.mapExclusions( + exclusion, + asCivil: asCivil, + )) + DavRawPropertyValue( + value: mapped.rawValue, + parameters: mapped.parameters, + ), + ]), + ); + } + final replacements = []; + final keys = {}; + final ranged = semantic.components.any( + (component) => + component.uid == uid && + component.documentComponent + .firstProperty('RECURRENCE-ID') + ?.parameterValue('RANGE') == + 'THISANDFUTURE', + ); + if (ranged && + (master.recurrenceDates.isNotEmpty || + change.allDay != master.start!.isDate || + !change.preservesFollowingOverride())) { + throw const SeriesTimingDecisionRequired( + 'The existing following override cannot keep its schedule under this pattern. Choose how to retain that portion before changing the series.', + ); + } + for (final exception in semantic.components.where( + (component) => component.uid == uid && component.recurrenceId != null, + )) { + final mapped = change.mapOccurrence(asCivil(exception.recurrenceId!)); + if (mapped == null || !change.containsNewOccurrence(mapped)) { + throw const SeriesTimingDecisionRequired( + 'An existing exception cannot be associated with the new pattern safely.', + ); + } + final value = + change.timeZone == null && + master.start!.kind == IcalTemporalKind.utcDateTime + ? mapped.toUtc() + : providerCivilDateTime(mapped); + final temporal = _eventTemporal( + value, + allDay: change.allDay, + timeZone: change.timeZone, + ); + final replacement = exception.documentComponent.deepCopy(); + final oldId = replacement.firstProperty('RECURRENCE-ID')!; + replacement.children[replacement.children.indexOf(oldId)] = IcalProperty( + group: oldId.group, + name: 'RECURRENCE-ID', + parameters: [ + ...temporal.parameters, + ...oldId.parameters.where((parameter) => parameter.name == 'RANGE'), + ], + rawValue: temporal.value, + originalPhysicalLines: const [], + isDirty: true, + ); + replacement.structurallyDirty = true; + if (!keys.add( + icalRecurrenceIdKey(replacement.firstProperty('RECURRENCE-ID'))!, + )) { + throw const SeriesTimingDecisionRequired( + 'Two exceptions would acquire the same occurrence identity.', + ); + } + operations.add( + DavPatchOperation.removeComponent( + componentKey: IcalComponentKey( + componentType: 'VEVENT', + uid: exception.uid!, + recurrenceIdKey: exception.recurrenceIdKey, + ), + ), + ); + replacements.add(replacement); + } + // Remove all old keys before adding replacements; a shifted key may equal a + // sibling's old key without representing the same occurrence. + operations.addAll(replacements.map(DavPatchOperation.addComponent)); + return operations; +} + /// Creates a detached exception for one generated occurrence while retaining /// the master, sibling exceptions, VTIMEZONEs, alarms, and unknown content in /// the same calendar-object resource. @@ -974,6 +1126,75 @@ DavMutationPatch? buildDavTaskUpdatePatch({ operations ..add(DavPatchOperation.setRaw('LAST-MODIFIED', timestamp)) ..add(DavPatchOperation.setRaw('DTSTAMP', timestamp)); + if (target.recurrenceIdKey == null && + fields.keys.any( + const { + 'status', + 'taskStatus', + 'percentComplete', + 'completedAtUtc', + 'completed', + }.contains, + )) { + final history = completedDavTaskCurrentInstance(semantic, component); + if (history != null) { + final masterPatch = DavMutationPatch( + target: target, + scope: DavMutationScope.recurrenceMaster, + operations: operations, + ); + final updatedMaster = + IcalSemanticDocument.parse( + masterPatch.applyTo( + baselineRawIcs, + nowUtc: (nowUtc ?? DateTime.now)(), + ), + ).components.singleWhere( + (value) => + value.componentType == 'VTODO' && + value.uid == target.uid && + value.recurrenceId == null, + ); + if (updatedMaster.taskUiState == IcalTaskUiState.open || + updatedMaster.taskUiState == IcalTaskUiState.inProgress) { + final historyTarget = IcalComponentKey( + componentType: 'VTODO', + uid: target.uid, + recurrenceIdKey: history.recurrenceIdKey, + ); + final historyPatch = buildDavTaskUpdatePatch( + target: historyTarget, + baselineRawIcs: baselineRawIcs, + fields: { + for (final key in [ + 'status', + 'taskStatus', + 'percentComplete', + 'completedAtUtc', + 'completed', + ]) + if (fields.containsKey(key)) key: fields[key], + }, + nowUtc: nowUtc, + )!; + final reopened = + IcalSemanticDocument.parse( + historyPatch.applyTo( + baselineRawIcs, + nowUtc: (nowUtc ?? DateTime.now)(), + ), + ).components.singleWhere( + (value) => + value.componentType == 'VTODO' && + value.uid == target.uid && + value.recurrenceIdKey == history.recurrenceIdKey, + ); + operations + ..add(DavPatchOperation.removeComponent(componentKey: historyTarget)) + ..add(DavPatchOperation.addComponent(reopened.documentComponent)); + } + } + } return DavMutationPatch( target: target, scope: target.recurrenceIdKey == null @@ -983,6 +1204,40 @@ DavMutationPatch? buildDavTaskUpdatePatch({ ); } +/// Nextcloud retains the exhausted UNTIL master. Its current instance is +/// already represented by completed history and must not become actionable +/// again merely because the wire master itself is still open. +IcalSemanticComponent? completedDavTaskCurrentInstance( + IcalSemanticDocument document, + IcalSemanticComponent master, +) { + final history = davTaskCurrentInstanceHistory(document, master); + return history?.taskUiState == IcalTaskUiState.completed ? history : null; +} + +IcalSemanticComponent? davTaskCurrentInstanceHistory( + IcalSemanticDocument document, + IcalSemanticComponent master, +) { + if (master.componentType != 'VTODO' || + master.recurrenceId != null || + master.taskUiState == IcalTaskUiState.cancelled || + master.recurrenceRules.isEmpty) { + return null; + } + final current = master.due ?? master.start; + if (current == null) return null; + final identity = _nextcloudDetachedTemporal(current).key; + for (final component in document.components) { + if (component.componentType == 'VTODO' && + component.uid == master.uid && + component.recurrenceIdKey == identity) { + return component; + } + } + return null; +} + /// Completes the current instance of a recurring VTODO while retaining an /// open master for the next instance. /// @@ -1026,14 +1281,14 @@ DavMutationPatch buildDavRecurringTaskCompletionPatch({ } final recurrenceId = _nextcloudDetachedTemporal(instance); - if (semantic.components.any( - (component) => - component.componentType == 'VTODO' && - component.uid == target.uid && - component.recurrenceIdKey == recurrenceId.key, - )) { - throw _invalidMutation(); - } + final existingHistory = semantic.components + .where( + (component) => + component.componentType == 'VTODO' && + component.uid == target.uid && + component.recurrenceIdKey == recurrenceId.key, + ) + .firstOrNull; final now = (nowUtc ?? (() => DateTime.now().toUtc()))().toUtc(); final timestamp = _utcIcal(now); @@ -1079,9 +1334,39 @@ DavMutationPatch buildDavRecurringTaskCompletionPatch({ originalEndLine: 'END:VTODO', structurallyDirty: true, ); - final operations = [ - DavPatchOperation.addComponent(exception), - ]; + final operations = []; + if (existingHistory == null) { + operations.add(DavPatchOperation.addComponent(exception)); + } else { + // Re-completing an explicitly reopened final instance updates that same + // component, retaining its identity and unsupported provider content. + final historyTarget = IcalComponentKey( + componentType: 'VTODO', + uid: target.uid, + recurrenceIdKey: recurrenceId.key, + ); + final historyPatch = buildDavTaskUpdatePatch( + target: historyTarget, + baselineRawIcs: baselineRawIcs, + fields: {'status': 'completed', 'completedAtUtc': completionTimestamp}, + nowUtc: () => now, + )!; + final completedHistory = + IcalSemanticDocument.parse( + historyPatch.applyTo( + baselineRawIcs, + nowUtc: (nowUtc ?? DateTime.now)(), + ), + ).components.singleWhere( + (value) => + value.componentType == 'VTODO' && + value.uid == target.uid && + value.recurrenceIdKey == recurrenceId.key, + ); + operations + ..add(DavPatchOperation.removeComponent(componentKey: historyTarget)) + ..add(DavPatchOperation.addComponent(completedHistory.documentComponent)); + } final firstRule = master.recurrenceRules.first; final count = _rruleCount(firstRule); diff --git a/lib/src/dav/nextcloud/nextcloud_collection_controller.dart b/lib/src/dav/nextcloud/nextcloud_collection_controller.dart index c33dcc95..651dd089 100644 --- a/lib/src/dav/nextcloud/nextcloud_collection_controller.dart +++ b/lib/src/dav/nextcloud/nextcloud_collection_controller.dart @@ -19,6 +19,7 @@ final class NextcloudCollectionController extends ChangeNotifier { bool failed = false; bool refreshPending = false; Object? error; + Map fieldErrors = {}; bool _disposed = false; int _searchGeneration = 0; final values = {}; @@ -45,14 +46,20 @@ final class NextcloudCollectionController extends ChangeNotifier { }); void change(DavPropertyName name, String value) { values[name] = value; + fieldErrors.remove(name); _notify(); } Future save() => _run(() async { - final result = await service.collections.update(collectionId, { + final changes = { for (final entry in values.entries) if (_baseline[entry.key] != entry.value) entry.key: entry.value, - }); + }; + fieldErrors = Map.of(validateNextcloudCollectionChanges(changes)); + if (fieldErrors.isNotEmpty) { + throw NextcloudCollectionValidationException(fieldErrors); + } + final result = await service.collections.update(collectionId, changes); refreshPending = result == NextcloudMutationOutcome.refreshPending; _baseline ..clear() diff --git a/lib/src/dav/nextcloud/nextcloud_collection_service.dart b/lib/src/dav/nextcloud/nextcloud_collection_service.dart index 58b7f736..5aa4cb94 100644 --- a/lib/src/dav/nextcloud/nextcloud_collection_service.dart +++ b/lib/src/dav/nextcloud/nextcloud_collection_service.dart @@ -294,43 +294,77 @@ final nextcloudWritableProperties = { DavPropertyName(owncloudNamespace, 'calendar-enabled'), }; -void _validateChanges(Map changes) { +enum NextcloudCollectionValidation { required, color, order, timezone, value } + +final class NextcloudCollectionValidationException extends ArgumentError { + NextcloudCollectionValidationException(this.fields) + : super('Invalid collection settings'); + final Map fields; +} + +Map +validateNextcloudCollectionChanges(Map changes) { + final errors = {}; for (final entry in changes.entries) { - if (!nextcloudWritableProperties.contains(entry.key) || - entry.value.length > 65536) { - throw ArgumentError('Unsupported collection property'); - } final value = entry.value; - switch (entry.key.localName) { - case 'displayname': - if (value.trim().isEmpty) throw ArgumentError('Empty collection name'); - case 'calendar-color': - if (!RegExp(r'^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$').hasMatch(value)) { - throw ArgumentError('Invalid collection color'); - } - case 'calendar-order': - if (int.tryParse(value) == null) { - throw ArgumentError('Invalid collection order'); - } - case 'calendar-enabled': - if (value != '0' && value != '1') { - throw ArgumentError('Invalid enabled state'); - } - case 'schedule-calendar-transp': - if (value != 'opaque' && value != 'transparent') { - throw ArgumentError('Invalid transparency'); - } - case 'calendar-timezone': - if (value.isNotEmpty) { - final document = IcalDocument.parse(value); - if (document.root.name != 'VCALENDAR' || - document.root.components.isEmpty || - document.root.components.any((c) => c.name != 'VTIMEZONE')) { - throw ArgumentError('Invalid calendar timezone document'); + NextcloudCollectionValidation? error; + if (!nextcloudWritableProperties.contains(entry.key) || + value.length > 65536) { + error = NextcloudCollectionValidation.value; + } else { + switch (entry.key.localName) { + case 'displayname': + if (value.trim().isEmpty) { + error = NextcloudCollectionValidation.required; } - } + case 'calendar-color': + if (!RegExp(r'^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$').hasMatch(value)) { + error = NextcloudCollectionValidation.color; + } + case 'calendar-order': + if (int.tryParse(value) == null) { + error = NextcloudCollectionValidation.order; + } + case 'calendar-enabled': + if (value != '0' && value != '1') { + error = NextcloudCollectionValidation.value; + } + case 'schedule-calendar-transp': + if (value != 'opaque' && value != 'transparent') { + error = NextcloudCollectionValidation.value; + } + case 'calendar-timezone': + if (value.isNotEmpty) { + try { + final document = IcalDocument.parse(value); + if (document.root.name != 'VCALENDAR' || + document.root.components.isEmpty || + document.root.components.any((c) => c.name != 'VTIMEZONE') || + document.root.components.any( + (c) => + c + .firstProperty('TZID') + ?.decodedTextValue + .trim() + .isEmpty != + false, + )) { + error = NextcloudCollectionValidation.timezone; + } + } on Object { + error = NextcloudCollectionValidation.timezone; + } + } + } } + if (error != null) errors[entry.key] = error; } + return Map.unmodifiable(errors); +} + +void _validateChanges(Map changes) { + final errors = validateNextcloudCollectionChanges(changes); + if (errors.isNotEmpty) throw NextcloudCollectionValidationException(errors); } String _propertyXml(DavPropertyName name, String value) { diff --git a/lib/src/dav/presentation/nextcloud_collection_dialog.dart b/lib/src/dav/presentation/nextcloud_collection_dialog.dart index 8e8b1187..b7c43b7c 100644 --- a/lib/src/dav/presentation/nextcloud_collection_dialog.dart +++ b/lib/src/dav/presentation/nextcloud_collection_dialog.dart @@ -14,6 +14,7 @@ import '../../dav/nextcloud/nextcloud_dav_context.dart'; import '../../dav/nextcloud/nextcloud_trash_service.dart'; import '../../dav/xml/dav_xml.dart'; import '../../l10n/l10n.dart'; +import '../../l10n/nextcloud_collection_error_description.dart'; import '../../ui/common/schedule/native_collection_export.dart'; import 'nextcloud_scheduling_dialog.dart'; @@ -119,6 +120,10 @@ class _LinuxNextcloudCollectionDialogState context, labelText: label, alignLabelWithHint: multiline, + errorText: localizedNextcloudFieldError( + context.l10n, + model.fieldErrors[property], + ), ), onChanged: (value) => model.change(property, value), ), @@ -189,14 +194,9 @@ class _LinuxNextcloudCollectionDialogState ), children: [ if (model.busy) const Center(child: YaruCircularProgressIndicator()), - if (model.failed) - Text( - model.error is DavException && - (model.error! as DavException).kind == - DavErrorKind.authorization - ? l10n.nextcloudOperationDenied - : l10n.nextcloudServerUnavailable, - ), + if (model.failed && + model.error is! NextcloudCollectionValidationException) + Text(localizedNextcloudCollectionError(l10n, model.error)), if (model.refreshPending) Text(l10n.nextcloudRefreshPending), if (state == null && !model.busy) BusyMaxGroupedList( @@ -219,11 +219,6 @@ class _LinuxNextcloudCollectionDialogState title: Text(state.collection.collection.displayName), subtitle: Text(role), ), - if (state.collection.collection.ownerHref case final owner?) - YaruListTile.square( - leading: const Icon(Icons.person_outline), - title: SelectableText(owner), - ), if (state.collection.collection.readOnly && state.collection.canWriteMetadata) YaruListTile.square( @@ -249,9 +244,29 @@ class _LinuxNextcloudCollectionDialogState ), l10n.description, ), - _field( - const DavPropertyName(caldavNamespace, 'calendar-timezone'), - l10n.nextcloudCalendarTimezone, + YaruExpandable( + header: Text(l10n.advancedSection), + expandIconSemanticLabel: l10n.advancedSection, + isExpanded: model.fieldErrors.keys.any( + (p) => p.localName == 'calendar-timezone', + ), + child: Column( + children: [ + if (state.collection.collection.ownerHref + case final owner?) + YaruListTile.square( + leading: const Icon(Icons.person_outline), + title: SelectableText(owner), + ), + _field( + const DavPropertyName( + caldavNamespace, + 'calendar-timezone', + ), + l10n.nextcloudCalendarTimezone, + ), + ], + ), ), _check( const DavPropertyName(owncloudNamespace, 'calendar-enabled'), diff --git a/lib/src/dav/storage/dav_object_repository.dart b/lib/src/dav/storage/dav_object_repository.dart index 9fe90774..df2bb61c 100644 --- a/lib/src/dav/storage/dav_object_repository.dart +++ b/lib/src/dav/storage/dav_object_repository.dart @@ -15,14 +15,35 @@ import '../ical/ical_recurrence.dart'; import '../ical/ical_semantics.dart'; import '../ical/ical_timezone.dart'; import '../mutation/dav_mutation_patch.dart'; +import '../mutation/dav_projection_mutations.dart'; import '../mutation/dav_pending_operation_selection.dart'; import '../nextcloud/nextcloud_scheduling_policy.dart'; import 'dav_collection_capabilities.dart'; const davRawObjectParserVersion = 1; -const davProjectionVersion = 5; +const davProjectionVersion = 6; const davSyncStateSchemaVersion = 1; +/// Shared temporal projection for synchronized and queued DAV task resources. +Map davTaskTemporalMetadata({ + required IcalTemporalValue? start, + required IcalTemporalValue? due, + required IcalTimeZoneResolver timeZoneResolver, +}) { + final startUtc = start == null + ? null + : _resolvedTemporalUtc(start, timeZoneResolver); + final dueUtc = due == null + ? null + : _resolvedTemporalUtc(due, timeZoneResolver); + return { + if (start != null) 'nativeStart': _temporalJson(start), + if (due != null) 'nativeDue': _temporalJson(due), + if (startUtc != null) 'startUtc': startUtc.toIso8601String(), + if (dueUtc != null) 'dueUtc': dueUtc.toIso8601String(), + }; +} + Map nextcloudParticipantProjection( Map raw, NextcloudSchedulingPolicy policy, @@ -1375,6 +1396,7 @@ final class DavObjectRepository { ); final timeZoneResolver = IcalTimeZoneResolver.fromDocument(semantic); final now = commit.completedAtUtc.toUtc().toIso8601String(); + final currentHistory = davTaskCurrentInstanceHistory(semantic, master); for (final component in semantic.components) { final componentId = componentIds[_componentKey( @@ -1389,7 +1411,14 @@ final class DavObjectRepository { final due = component.due ?? (component == master ? null : master.due); final start = component.start ?? (component == master ? null : master.start); + final completedHistory = component == master + ? completedDavTaskCurrentInstance(semantic, master) + : null; + final effectiveTaskState = completedHistory == null + ? component.taskUiState + : IcalTaskUiState.completed; final completed = + completedHistory?.completed ?? component.completed ?? (component.taskUiState == IcalTaskUiState.completed ? master.completed @@ -1407,24 +1436,25 @@ final class DavObjectRepository { due: due, timeZoneResolver: timeZoneResolver, ); - final startUtc = start == null - ? null - : _resolvedTemporalUtc(start, timeZoneResolver); - final dueUtc = due == null - ? null - : _resolvedTemporalUtc(due, timeZoneResolver); final metadata = { 'transport': 'caldav', 'uid': component.uid, - if (start != null) 'nativeStart': _temporalJson(start), - if (due != null) 'nativeDue': _temporalJson(due), - if (startUtc != null) 'startUtc': startUtc.toIso8601String(), - if (dueUtc != null) 'dueUtc': dueUtc.toIso8601String(), + if (component == currentHistory && + (component.taskUiState == IcalTaskUiState.open || + component.taskUiState == IcalTaskUiState.inProgress) && + (master.taskUiState == IcalTaskUiState.open || + master.taskUiState == IcalTaskUiState.inProgress)) + 'representedByMaster': true, + ...davTaskTemporalMetadata( + start: start, + due: due, + timeZoneResolver: timeZoneResolver, + ), if (component.recurrenceId != null) 'recurrenceId': _temporalJson(component.recurrenceId!), 'alarms': _alarmProjection(alarms), }; - final taskStatus = switch (component.taskUiState) { + final taskStatus = switch (effectiveTaskState) { IcalTaskUiState.completed => 'completed', IcalTaskUiState.inProgress => 'inProcess', IcalTaskUiState.open => 'needsAction', @@ -1449,7 +1479,9 @@ final class DavObjectRepository { recurrenceIdKey: Value(component.recurrenceIdKey), icalPriority: Value(priority), percentComplete: Value( - component.percentComplete ?? master.percentComplete, + completedHistory != null + ? 100 + : component.percentComplete ?? master.percentComplete, ), taskLocation: Value(effectiveIcalLocation([component, master])), locationLatitude: Value( @@ -1491,7 +1523,11 @@ final class DavObjectRepository { microsoftReminderTimeZone: Value(reminder?.timeZone), microsoftIsReminderOn: Value(reminder != null), completedUtc: Value(_storageTemporalNullable(completed)), - providerStatus: Value(component.status ?? master.status), + providerStatus: Value( + completedHistory != null + ? 'COMPLETED' + : component.status ?? master.status, + ), recurrenceJson: Value( jsonEncode({ 'rules': master.recurrenceRules, diff --git a/lib/src/db/app_database.dart b/lib/src/db/app_database.dart index 6ced51b5..80ddeb3d 100644 --- a/lib/src/db/app_database.dart +++ b/lib/src/db/app_database.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart'; diff --git a/lib/src/db/daos/tasks_dao.dart b/lib/src/db/daos/tasks_dao.dart index 7f6327ee..8f2f605c 100644 --- a/lib/src/db/daos/tasks_dao.dart +++ b/lib/src/db/daos/tasks_dao.dart @@ -1,5 +1,19 @@ part of '../app_database.dart'; +/// A reopened current recurrence uses the master's stable task identity. Its +/// detached history remains cached, without a second active task or reminder. +bool isVisibleTaskProjection(Task task) { + if (task.recurrenceIdKey == null) return true; + final source = task.providerMetadataJson; + if (source == null) return true; + try { + final metadata = jsonDecode(source); + return metadata is! Map || metadata['representedByMaster'] != true; + } on FormatException { + return true; + } +} + @DriftAccessor(tables: [Accounts, DavCollections, TaskLists, Tasks]) class TasksDao extends DatabaseAccessor with _$TasksDaoMixin { TasksDao(super.db); @@ -31,7 +45,11 @@ class TasksDao extends DatabaseAccessor with _$TasksDaoMixin { OrderingTerm.asc(tasks.title), ]); return query.watch().map( - (rows) => [for (final row in rows) row.readTable(tasks)], + (rows) => [ + for (final row in rows) + if (isVisibleTaskProjection(row.readTable(tasks))) + row.readTable(tasks), + ], ); } @@ -82,11 +100,12 @@ class TasksDao extends DatabaseAccessor with _$TasksDaoMixin { return query.watch().map((rows) { return [ for (final row in rows) - TaskWithContextRow( - task: row.readTable(tasks), - taskList: row.readTable(taskLists), - account: row.readTable(accounts), - ), + if (isVisibleTaskProjection(row.readTable(tasks))) + TaskWithContextRow( + task: row.readTable(tasks), + taskList: row.readTable(taskLists), + account: row.readTable(accounts), + ), ]; }); } diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart index f3063e38..2a9dcc7a 100644 --- a/lib/src/db/migrations.dart +++ b/lib/src/db/migrations.dart @@ -20,93 +20,107 @@ final class BusyMaxMigrationException implements Exception { MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { return MigrationStrategy( onCreate: (migrator) async { - await migrator.createAll(); - await _createContactsSchema(database); - await _createIndexes(database); - await createLocationLifecycleTriggers(database); - await _verifyForeignKeys(database); - }, - onUpgrade: (migrator, from, to) async { - // Later legacy migrations rebuild the accounts table against the current - // schema, so additive account columns must exist before those rebuilds. - if (from < 16) { - await _addColumnIfMissing( - migrator, - database, - database.accounts, - database.accounts.taskImportIncomplete, - ); - } - if (from < 2 && await _hasTable(database, 'pending_ops')) { - await migrator.addColumn( - database.pendingOps, - database.pendingOps.baselineRawJson, - ); - } - if (from < 3) { - await _addV3Columns(migrator, database); - } - if (from < 4) { - await _addV4CalendarTables(migrator, database); - } - if (from >= 4 && from < 5) { - await _addV5CalendarEventCategories(migrator, database); - } - if (from < 6) { - await _migrateToV6(migrator, database); - } - if (from < 7) { - await _migrateToV7(migrator, database); - } - if (from < 8) { - await _migrateToV8(migrator, database); - } - if (from < 9) { - await _migrateToV9(migrator, database); - } - if (from < 10) { - await _migrateToV10(migrator, database); - } - if (from < 11) { - await _migrateToV11(migrator, database); - } - if (from < 12) { - await _migrateToV12(migrator, database); - } - if (from < 13) { - await _migrateToV13(migrator, database); - } - if (from < 14) await migrateLocationData(migrator, database); - if (from < 15) { - await _addColumnIfMissing( - migrator, - database, - database.notificationSchedule, - database.notificationSchedule.generation, - ); - } - if (from < 17) { - await migrator.createTable(database.androidNotificationMappings); - } - if (from < 18) { - await migrator.createTable(database.androidDailySummarySchedules); - } - if (from < 19) { - await migrator.createTable(database.oAuthTransitionAccounts); - await migrator.createTable(database.accountAuthorizations); - await migrator.createTable(database.authorizationCommits); - await migrator.createTable(database.authorizationGenerations); - await migrator.createTable(database.domainSyncSchedules); + await database.transaction(() async { + await migrator.createAll(); + await _createContactsSchema(database); + await _createIndexes(database); + await createLocationLifecycleTriggers(database); + await _verifyForeignKeys(database); await database.customStatement( - "INSERT INTO ${database.oAuthTransitionAccounts.actualTableName} (account_id) SELECT id FROM accounts WHERE provider IN ('google', 'microsoft')", + 'PRAGMA user_version = ${database.schemaVersion}', ); - } - if (from < 20) { - await _createContactsSchema(database); - } - await _createIndexes(database); - await createLocationLifecycleTriggers(database); - await _verifyForeignKeys(database); + }); + }, + onUpgrade: (migrator, from, to) async { + // SQLite ignores foreign_keys changes in a transaction. Table rebuilds + // need it disabled before entering the migration, and beforeOpen restores it. + await database.customStatement('PRAGMA foreign_keys = OFF'); + await database.transaction(() async { + // Later legacy migrations rebuild the accounts table against the current + // schema, so additive account columns must exist before those rebuilds. + if (from < 16) { + await _addColumnIfMissing( + migrator, + database, + database.accounts, + database.accounts.taskImportIncomplete, + ); + } + if (from < 2 && await _hasTable(database, 'pending_ops')) { + await migrator.addColumn( + database.pendingOps, + database.pendingOps.baselineRawJson, + ); + } + if (from < 3) { + await _addV3Columns(migrator, database); + } + if (from < 4) { + await _addV4CalendarTables(migrator, database); + } + if (from >= 4 && from < 5) { + await _addV5CalendarEventCategories(migrator, database); + } + if (from < 6) { + await _migrateToV6(migrator, database); + } + if (from < 7) { + await _migrateToV7(migrator, database); + } + if (from < 8) { + await _migrateToV8(migrator, database); + } + if (from < 9) { + await _migrateToV9(migrator, database); + } + if (from < 10) { + await _migrateToV10(migrator, database); + } + if (from < 11) { + await _migrateToV11(migrator, database); + } + if (from < 12) { + await _migrateToV12(migrator, database); + } + if (from < 13) { + await _migrateToV13(migrator, database); + } + if (from < 14) await migrateLocationData(migrator, database); + if (from < 15) { + await _addColumnIfMissing( + migrator, + database, + database.notificationSchedule, + database.notificationSchedule.generation, + ); + } + if (from < 17) { + await migrator.createTable(database.androidNotificationMappings); + } + if (from < 18) { + await migrator.createTable(database.androidDailySummarySchedules); + } + if (from < 19) { + await migrator.createTable(database.oAuthTransitionAccounts); + await migrator.createTable(database.accountAuthorizations); + await migrator.createTable(database.authorizationCommits); + await migrator.createTable(database.authorizationGenerations); + await migrator.createTable(database.domainSyncSchedules); + await database.customStatement( + "INSERT INTO ${database.oAuthTransitionAccounts.actualTableName} (account_id) SELECT id FROM accounts WHERE provider IN ('google', 'microsoft')", + ); + } + if (from < 20) { + await _createContactsSchema(database); + } + await _createIndexes(database); + await createLocationLifecycleTriggers(database); + await _verifyForeignKeys(database); + // Drift's native executor sets user_version again after beforeOpen. + // Commit it with the schema/data so interruption after this callback + // cannot retry an already-committed non-idempotent migration. + await database.customStatement('PRAGMA user_version = $to'); + }); }, beforeOpen: (details) async { await database.customStatement('PRAGMA foreign_keys = ON'); diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 20f995c4..a380d826 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; +import 'package:timezone/timezone.dart' as time_zone; import '../../../calendar_providers/calendar_colors.dart'; import '../../../calendar_providers/calendar_description.dart'; @@ -19,6 +20,7 @@ import '../../../calendar_providers/calendar_sync_dto.dart'; import '../../../core/time/provider_date_time.dart'; import '../../../core/time/stored_temporal_projection.dart'; import '../../../dav/ical/ical_document.dart'; +import '../../../dav/ical/ical_recurrence.dart'; import '../../../dav/ical/ical_semantics.dart'; import '../../../dav/ical/ical_timezone.dart'; import '../../../dav/mutation/dav_mutation_patch.dart'; @@ -32,6 +34,8 @@ import '../../../microsoft_calendar/microsoft_shared_calendar_address.dart'; import '../../accounts/domain/account_collection_creation_capabilities.dart'; import '../../notifications/notification_schedule_service.dart'; import '../../recurrence/domain/event_recurrence_codec.dart'; +import '../../recurrence/domain/series_timing_change.dart'; +import '../../recurrence/domain/recurrence_rule.dart'; import '../../maps/domain/geographic_point.dart'; import '../domain/event_move_policy.dart'; import '../domain/google_status_event.dart'; @@ -1298,6 +1302,35 @@ class CalendarRepository { _calendarOperationType(op) == 'event.create') { return; } + final request = _jsonMap(op.requestJson); + final seriesTiming = request[calendarEventSeriesTimingKey] is Map; + if (seriesTiming && request[calendarEventSeriesCheckedMasterKey] is Map) { + // A master update can reset exceptions. Retain their durable snapshots + // until replay has restored them, including when the HTTP response was + // lost. An unchanged checked revision proves that no write was accepted. + final checked = _jsonObjectMap( + request[calendarEventSeriesCheckedMasterKey], + ); + final masterId = request[calendarEventTargetProviderIdKey]?.toString(); + final checkedRevision = checked['etag'] ?? checked['changeKey']; + final providerMaster = + fetchProviderEvent != null && + masterId != null && + checkedRevision != null + ? await fetchProviderEvent( + calendarId: op.providerCalendarId!, + eventId: masterId, + ) + : null; + if (providerMaster == null || + providerMaster.etagOrChangeKey != checkedRevision || + request[calendarEventSeriesAppliedMasterKey] is Map) { + throw StateError( + 'The provider may have accepted part of this series edit. Retry must ' + 'finish preserving its exceptions before this recovery record can be discarded.', + ); + } + } final eventId = op.eventId; if (eventId == null) { throw StateError('The event recovery identity is unavailable.'); @@ -1308,11 +1341,14 @@ class CalendarRepository { if (target == null) return; final provider = BusyProviderCodec.requireStorageValue(target.provider); CalendarEventDto? providerSnapshot; - final operationBaseline = _jsonMap(op.baselineRawJson); + final operationBaseline = seriesTiming + ? const {} + : _jsonMap(op.baselineRawJson); final recoveryBaseline = operationBaseline.isNotEmpty ? operationBaseline : _jsonMap(target.baselineRawJson); - if (!_isCompleteProviderEventBaseline(provider, recoveryBaseline)) { + if (!seriesTiming && + !_isCompleteProviderEventBaseline(provider, recoveryBaseline)) { if (fetchProviderEvent == null) { throw StateError('The provider event recovery client is unavailable.'); } @@ -1346,6 +1382,7 @@ class CalendarRepository { )..where((row) => row.id.equals(eventId))).getSingleOrNull(); if (target == null) return; final request = _jsonMap(op.requestJson); + final seriesTiming = request[calendarEventSeriesTimingKey]; final seriesProviderEventId = request[calendarEventTargetProviderIdKey] ?.toString(); final affectedEvents = seriesProviderEventId == null @@ -1372,11 +1409,36 @@ class CalendarRepository { row.eventId.isIn(affectedIds), )) .get(); - if (remainingOperations.isNotEmpty) return; + if (remainingOperations.isNotEmpty) { + if (seriesTiming is Map) { + throw StateError( + 'This series edit has dependent edits. Resolve those edits before ' + 'discarding their recurrence identities.', + ); + } + return; + } for (final event in affectedEvents) { + if (seriesTiming is Map) { + final projection = _jsonMap(event.rawJson)['_seriesTimingProjection']; + if (event.providerEventId.startsWith('local:series:') && + projection is Map && + projection['signature'] == seriesTiming['signature']) { + await (_database.delete( + _database.calendarEvents, + )..where((row) => row.id.equals(event.id))).go(); + continue; + } + // Provider cancellation markers can contain only identity. They were + // never optimistically edited and need no complete event baseline. + if (event.syncStatus == 'synced' && + (event.isCancelled || event.isDeleted)) { + continue; + } + } final provider = BusyProviderCodec.requireStorageValue(event.provider); - final operationBaseline = event.id == eventId + final operationBaseline = event.id == eventId && seriesTiming is! Map ? _jsonMap(op.baselineRawJson) : const {}; final restored = event.id == eventId && providerSnapshot != null @@ -1657,6 +1719,36 @@ class CalendarRepository { var stored = false; await _database.transaction(() async { + final seriesEdits = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(accountId) & + row.provider.equals(event.provider.storageValue) & + row.providerCalendarId.equals(event.providerCalendarId) & + row.entityType.equals('event'), + )) + .get(); + for (final edit in seriesEdits) { + final request = _jsonMap(edit.requestJson); + if (request[calendarEventSeriesTimingKey] is! Map) continue; + final target = request[calendarEventTargetProviderIdKey]; + final applied = request[calendarEventSeriesAppliedMasterKey]; + final split = + event.provider == BusyProvider.google && + request[calendarEventRecurringScopeKey] == 'thisAndFuture' + ? edit.id.replaceAll('-', '').toLowerCase() + : null; + final identities = { + target, + if (applied is Map) applied['id'], + if (split != null) split, + }; + if (identities.contains(event.providerEventId) || + (event.providerRecurringEventId != null && + identities.contains(event.providerRecurringEventId))) { + return; + } + } final localEvent = await (_database.select( _database.calendarEvents, )..where((row) => row.id.equals(id))).getSingleOrNull(); @@ -2109,6 +2201,7 @@ class CalendarRepository { start: draft.start, end: draft.end, recurringMutationScope: draft.recurringMutationScope, + seriesTimingApproval: draft.seriesTimingApproval, ), guestUpdatePolicy: guestUpdatePolicy, timingOnly: true, @@ -2124,6 +2217,68 @@ class CalendarRepository { } } + /// Returns the exact pattern proposal for the editor's confirmation step. + /// Saving recomputes it, so approval cannot cover a subsequently changed edit. + Future prepareSeriesTimingChange( + EventEditorDraft draft, { + bool timingOnly = false, + }) async { + final id = draft.eventId; + if (id == null || + draft.start == null || + draft.end == null || + (draft.recurringMutationScope != + RecurringEventMutationScope.entireSeries && + draft.recurringMutationScope != + RecurringEventMutationScope.thisAndFuture)) { + return null; + } + final existing = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(id))).getSingle(); + if (!_eventRequiresRecurringScope(existing) || + existing.calendarSourceId != draft.sourceId) { + return null; + } + if (existing.davObjectId != null) { + if (draft.recurringMutationScope != + RecurringEventMutationScope.entireSeries) { + return null; + } + final source = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(existing.calendarSourceId))).getSingle(); + final raw = await DavPendingOperationQueue(database: _database) + .editableRawIcsForObject( + accountId: existing.accountId, + collectionId: source.davCollectionId!, + objectId: existing.davObjectId!, + ); + final change = _davSeriesTimingChange( + raw, + existing, + draft.start!, + draft.end!, + draft.allDay, + draft.startTimeZone, + ); + if (change != null) { + davSeriesTimingExceptionOperations( + baselineRawIcs: raw, + uid: existing.icalUid!, + change: change, + ); + } + return change; + } + return _cloudSeriesTimingChange( + existing, + await _seriesMasterBaseline(existing), + draft, + shiftExceptions: timingOnly, + ); + } + Future _updateLocalEvent( EventEditorDraft draft, { CalendarGuestUpdatePolicy guestUpdatePolicy = @@ -2274,6 +2429,15 @@ class CalendarRepository { final seriesBaseline = seriesMutation ? await _seriesMasterBaseline(existing) : null; + final seriesTiming = seriesMutation + ? _cloudSeriesTimingChange( + existing, + seriesBaseline!, + draft, + shiftExceptions: timingOnly, + ) + : null; + _requireSeriesTimingApproval(draft, seriesTiming); final request = _eventDeltaRequest( draft, editBaseline, @@ -2351,6 +2515,12 @@ class CalendarRepository { request[calendarEventTimingBaselineKey] = _storedEventTimingBaseline( existing, ); + if (seriesTiming != null) { + request[calendarEventSeriesTimingKey] = seriesTiming.toJson(); + request[calendarEventRecurrenceField] = seriesTiming.encode( + _decodeStoredJson(seriesBaseline!.recurrenceJson)!, + ); + } } } final requestJson = jsonEncode(request); @@ -2358,7 +2528,10 @@ class CalendarRepository { final predecessor = await _latestPendingEventMutation( accountId: draft.accountId, eventId: eventId, - seriesEvent: seriesMutation ? existing : null, + seriesEvent: existing.providerRecurringEventId == null + ? null + : existing, + onlySeriesPredecessors: !seriesMutation, ); final projection = _eventPatchProjection( draft: draft, @@ -2381,6 +2554,8 @@ class CalendarRepository { endTimeZone: endTimeZone, now: now, timingOnly: timingOnly, + seriesTiming: seriesTiming, + seriesRequest: request, ); } else { await (_database.update( @@ -3243,6 +3418,8 @@ class CalendarRepository { required String? endTimeZone, required int now, bool timingOnly = false, + SeriesTimingChange? seriesTiming, + Map? seriesRequest, }) async { final recurringEventId = existing.providerRecurringEventId; if (recurringEventId == null) { @@ -3312,16 +3489,105 @@ class CalendarRepository { hideAttendeesChanged || newTimeProposalsChanged; + final pending = seriesTiming == null + ? const [] + : await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(existing.accountId) & + row.providerCalendarId.equals(existing.providerCalendarId) & + row.entityType.equals('event'), + )) + .get(); + final individuallyEdited = { + for (final operation in pending) + if (operation.eventId != null && + (_jsonMap(operation.requestJson)[calendarEventRecurringScopeKey] == + 'singleOccurrence') && + (_jsonMap(operation.requestJson).containsKey('start') || + _jsonMap(operation.requestJson).containsKey('end'))) + operation.eventId!, + }; + final masterRaw = seriesTiming == null + ? const {} + : _jsonMap((await _seriesMasterBaseline(existing)).rawJson); + final masterServerStart = _rawSeriesWall( + masterRaw, + 'start', + existing.startTimeZone, + ); + final masterServerEnd = _rawSeriesWall( + masterRaw, + 'end', + existing.startTimeZone, + ); + for (final row in rows) { if (!_seriesRowInScope(row, existing, scope)) continue; final rowStart = wall(_storedEventStartDateTime(row)); final rowEnd = wall(_storedEventEndDateTime(row)); - final shiftedStart = startChanged && rowStart != null - ? rowStart.add(startDelta) + final originalStart = providerDateTimeAsCivilTime( + row.providerOriginalStartKey, + row.startTimeZone, + ); + final rowRaw = _jsonMap(row.rawJson); + final serverStart = _rawSeriesWall( + rowRaw, + 'start', + existing.startTimeZone, + ); + final serverEnd = _rawSeriesWall(rowRaw, 'end', existing.startTimeZone); + final resolved = + seriesTiming != null && rowStart != null && rowEnd != null + ? seriesTiming.occurrenceTiming( + originalStart: originalStart ?? rowStart, + start: rowStart, + end: rowEnd, + baselineStart: serverStart, + baselineEnd: serverEnd, + masterDuration: + masterServerStart == null || masterServerEnd == null + ? null + : masterServerEnd.difference(masterServerStart), + endDelta: endDelta, + pendingTimingEdit: individuallyEdited.contains(row.id), + startChanged: startChanged, + endChanged: endChanged, + ) + : null; + final rescheduled = resolved?.preserved == true; + final applyStart = startChanged && !rescheduled; + final applyEnd = endChanged && !rescheduled; + final mapped = resolved?.start; + if (applyStart && seriesTiming != null && mapped == null) { + if (rowStart != null && + seriesTiming.removesOrdinaryOccurrence(rowStart)) { + await (_database.update( + _database.calendarEvents, + )..where((table) => table.id.equals(row.id))).write( + CalendarEventsCompanion( + isDeleted: const Value(true), + syncStatus: const Value('pending'), + updatedAtLocal: Value(now), + ), + ); + continue; + } + throw const SeriesTimingDecisionRequired( + 'An occurrence cannot be associated with the new pattern safely.', + ); + } + final shiftedStart = applyStart && rowStart != null + ? mapped ?? rowStart.add(startDelta) : rowStart; - final shiftedEnd = endChanged && rowEnd != null - ? rowEnd.add(endDelta) - : rowEnd; + final shiftedEnd = + resolved?.end ?? + (applyEnd && rowEnd != null + ? shiftedStart != null && rowStart != null + ? shiftedStart.add( + rowEnd.difference(rowStart) + endDelta - startDelta, + ) + : rowEnd.add(endDelta) + : rowEnd); final companion = CalendarEventsCompanion( title: draft.title.trim() != existing.title ? Value(draft.title.trim()) @@ -3338,13 +3604,13 @@ class CalendarRepository { locationLongitude: locationChanged && provider == BusyProvider.microsoft ? Value(draft.effectiveLocationPoint?.longitude) : const Value.absent(), - allDay: startChanged || endChanged + allDay: applyStart || applyEnd ? Value(draft.allDay) : const Value.absent(), - startDate: startChanged + startDate: applyStart ? Value(draft.allDay ? _date(shiftedStart) : null) : const Value.absent(), - startDateTime: startChanged + startDateTime: applyStart ? Value( draft.allDay || shiftedStart == null ? null @@ -3352,20 +3618,23 @@ class CalendarRepository { ) : const Value.absent(), startTimeZone: - startChanged || draft.startTimeZone != existing.startTimeZone + !rescheduled && + (startChanged || draft.startTimeZone != existing.startTimeZone) ? Value(draft.allDay ? null : startTimeZone) : const Value.absent(), - endDate: endChanged + endDate: applyEnd ? Value(draft.allDay ? _date(shiftedEnd) : null) : const Value.absent(), - endDateTime: endChanged + endDateTime: applyEnd ? Value( draft.allDay || shiftedEnd == null ? null : providerWallTimeIso8601String(shiftedEnd), ) : const Value.absent(), - endTimeZone: endChanged || draft.endTimeZone != existing.endTimeZone + endTimeZone: + !rescheduled && + (endChanged || draft.endTimeZone != existing.endTimeZone) ? Value(draft.allDay ? null : endTimeZone) : const Value.absent(), remindersJson: remindersChanged @@ -3420,6 +3689,55 @@ class CalendarRepository { : companion, ); } + if (seriesTiming != null && + scope == RecurringEventMutationScope.entireSeries) { + final master = await _seriesMasterBaseline(existing); + final masterStart = wall(_storedEventStartDateTime(master))!; + final masterEnd = wall(_storedEventEndDateTime(master))!; + final start = seriesTiming.masterStart; + final end = start.add( + masterEnd.difference(masterStart) + endDelta - startDelta, + ); + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(master.id))).write( + CalendarEventsCompanion( + allDay: Value(draft.allDay), + startDate: Value(draft.allDay ? _date(start) : null), + startDateTime: Value( + draft.allDay ? null : providerWallTimeIso8601String(start), + ), + startTimeZone: Value(draft.allDay ? null : startTimeZone), + endDate: Value(draft.allDay ? _date(end) : null), + endDateTime: Value( + draft.allDay ? null : providerWallTimeIso8601String(end), + ), + endTimeZone: Value(draft.allDay ? null : endTimeZone), + recurrenceJson: Value( + jsonEncode( + seriesTiming.encode(_decodeStoredJson(master.recurrenceJson)!), + ), + ), + syncStatus: const Value('pending'), + updatedAtLocal: Value(now), + ), + ); + } + if (seriesTiming != null && + seriesTiming.rule.isSupported && + { + RecurrenceFrequency.monthly, + RecurrenceFrequency.yearly, + }.contains(seriesTiming.rule.frequency)) { + await _completeCloudSeriesProjection( + existing, + draft, + scope, + seriesTiming, + now, + seriesRequest!, + ); + } if (provider == BusyProvider.google && locationChanged && scope == RecurringEventMutationScope.entireSeries) { @@ -3431,6 +3749,203 @@ class CalendarRepository { } } + Future _completeCloudSeriesProjection( + CalendarEvent existing, + EventEditorDraft draft, + RecurringEventMutationScope scope, + SeriesTimingChange change, + int now, + Map seriesRequest, + ) async { + final rows = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(existing.accountId) & + row.provider.equals(existing.provider) & + row.providerCalendarId.equals(existing.providerCalendarId) & + row.providerRecurringEventId.equals( + existing.providerRecurringEventId!, + ), + )) + .get(); + final represented = {}; + for (final row in rows) { + if (!_seriesRowInScope(row, existing, scope)) continue; + final original = providerDateTimeAsCivilTime( + row.providerOriginalStartKey, + existing.startTimeZone, + ); + if (original == null) continue; + final mapped = change.mapOccurrence(original); + if (mapped == null) continue; + represented.add(providerWallTimeIso8601String(mapped)); + if (!change.containsNewOccurrence(mapped) && + !row.isCancelled && + !row.isDeleted) { + await (_database.update( + _database.calendarEvents, + )..where((table) => table.id.equals(row.id))).write( + const CalendarEventsCompanion( + isDeleted: Value(true), + syncStatus: Value('pending'), + ), + ); + } + } + final master = await _seriesMasterBaseline(existing); + final provider = BusyProviderCodec.requireStorageValue(existing.provider); + final template = master.copyWithCompanion( + _eventPatchProjection( + draft: draft, + existing: master, + provider: provider, + request: seriesRequest, + startTimeZone: change.timeZone, + endTimeZone: draft.endTimeZone ?? change.timeZone, + conference: null, + now: now, + ), + ); + final duration = providerCivilDateTime(draft.end!) + .difference(providerCivilDateTime(draft.start!)); + final object = buildDavEventObject( + DavEventMutationInput( + title: draft.title, + allDay: draft.allDay, + start: providerCivilDateTime(change.masterStart), + end: providerCivilDateTime(change.masterStart).add(duration), + startTimeZone: change.timeZone, + endTimeZone: draft.endTimeZone ?? change.timeZone, + recurrence: { + 'rules': [change.rule.toRrule()], + }, + ), + idFactory: () => 'series-projection', + nowUtc: () => _now().toUtc(), + ); + final document = IcalSemanticDocument.parse(object.rawIcs); + final ranges = + await (_database.select(_database.syncCursors)..where( + (row) => + row.accountId.equals(existing.accountId) & + row.projectionSourceId.equals(existing.calendarSourceId) & + row.lastCompleteSyncAt.isNotNull() & + row.rangeStart.isNotNull() & + row.rangeEnd.isNotNull(), + )) + .get(); + final windows = <(DateTime, DateTime)>[ + for (final range in ranges) + (DateTime.parse(range.rangeStart!), DateTime.parse(range.rangeEnd!)), + ]; + if (windows.isEmpty) { + // Without a persisted coverage claim, project only the cached months. + final months = { + DateTime.utc(draft.start!.year, draft.start!.month), + for (final row in rows) + if (_storedEventStartDateTime(row) case final start?) + DateTime.utc(start.year, start.month), + }; + windows.addAll( + months.map( + (month) => (month, DateTime.utc(month.year, month.month + 1)), + ), + ); + } + for (final window in windows) { + for (final occurrence in IcalRecurrenceExpander().expand( + document, + rangeStartUtc: window.$1, + rangeEndUtc: window.$2, + )) { + final start = occurrence.start.localValue; + if (scope == RecurringEventMutationScope.thisAndFuture && + start.isBefore(change.desiredStart)) { + continue; + } + if (!represented.add(providerWallTimeIso8601String(start))) continue; + final end = occurrence.end!.localValue; + final key = draft.allDay + ? _date(start)! + : providerWallTimeToInstant( + start, + change.timeZone, + ).toIso8601String(); + final providerId = + 'local:series:${existing.providerRecurringEventId}:$key'; + final raw = { + ..._jsonMap(template.rawJson), + 'id': providerId, + if (existing.provider == 'google') ...{ + 'recurringEventId': existing.providerRecurringEventId, + 'originalStartTime': {draft.allDay ? 'date' : 'dateTime': key}, + } else ...{ + 'seriesMasterId': existing.providerRecurringEventId, + 'originalStart': key, + 'type': 'occurrence', + }, + 'start': { + draft.allDay ? 'date' : 'dateTime': draft.allDay + ? _date(start) + : providerWallTimeIso8601String(start), + if (!draft.allDay) 'timeZone': change.timeZone, + }, + 'end': { + draft.allDay ? 'date' : 'dateTime': draft.allDay + ? _date(end) + : providerWallTimeIso8601String(end), + if (!draft.allDay) 'timeZone': draft.endTimeZone ?? change.timeZone, + }, + '_seriesTimingProjection': { + 'signature': change.signature, + 'mapped': start.toIso8601String(), + }, + }..remove('recurrence'); + await _database + .into(_database.calendarEvents) + .insert( + template.copyWith( + id: eventId( + accountId: existing.accountId, + provider: BusyProviderCodec.requireStorageValue( + existing.provider, + ), + providerCalendarId: existing.providerCalendarId, + providerEventId: providerId, + providerOriginalStartKey: key, + ), + providerEventId: providerId, + providerOriginalStartKey: Value(key), + providerRecurringEventId: Value( + existing.providerRecurringEventId, + ), + isDeleted: false, + isCancelled: false, + allDay: draft.allDay, + startDate: Value(draft.allDay ? _date(start) : null), + startDateTime: Value( + draft.allDay ? null : providerWallTimeIso8601String(start), + ), + startTimeZone: Value(draft.allDay ? null : change.timeZone), + endDate: Value(draft.allDay ? _date(end) : null), + endDateTime: Value( + draft.allDay ? null : providerWallTimeIso8601String(end), + ), + endTimeZone: Value( + draft.allDay ? null : draft.endTimeZone ?? change.timeZone, + ), + recurrenceJson: const Value(null), + etagOrChangeKey: const Value(null), + rawJson: Value(jsonEncode(raw)), + baselineRawJson: const Value(null), + syncStatus: 'pending', + updatedAtLocal: now, + ), + ); + } + } + } + Future _markCloudSeriesDeleted({ required CalendarEvent existing, required RecurringEventMutationScope scope, @@ -3476,6 +3991,7 @@ class CalendarRepository { required String accountId, required String eventId, CalendarEvent? seriesEvent, + bool onlySeriesPredecessors = false, }) async { final eventIds = {eventId}; if (seriesEvent != null) { @@ -3521,7 +4037,17 @@ class CalendarRepository { (row) => OrderingTerm.desc(row.createdAtUtc), (row) => OrderingTerm.desc(row.updatedAtUtc), ]); - final edits = await query.get(); + final edits = (await query.get()) + .where( + (operation) => + !onlySeriesPredecessors || + operation.eventId == eventId || + _jsonMap( + operation.requestJson, + )[calendarEventTargetProviderIdKey] == + seriesEvent?.providerRecurringEventId, + ) + .toList(); final predecessorIds = { for (final edit in edits) if (edit.dependsOnOpId != null) edit.dependsOnOpId!, @@ -4081,11 +4607,37 @@ class CalendarRepository { ); } if (recurringScope == RecurringEventMutationScope.entireSeries) { + final seriesTiming = _davSeriesTimingChange( + baselineRawIcs, + existing, + input.start, + input.end, + input.allDay, + draft.startTimeZone, + ); + _requireSeriesTimingApproval(draft, seriesTiming); + final semantic = IcalSemanticDocument.parse(baselineRawIcs); + final master = semantic.components.singleWhere( + (component) => component.uid == uid && component.recurrenceId == null, + ); + final preserveDuration = + seriesTiming != null && + master.duration != null && + draft.allDay == existing.allDay && + draft.startTimeZone == existing.startTimeZone && + draft.endTimeZone == existing.endTimeZone && + providerCivilDateTime(input.start).difference( + providerCivilDateTime(_storedEventStartDateTime(existing)!), + ) == + providerCivilDateTime(input.end).difference( + providerCivilDateTime(_storedEventEndDateTime(existing)!), + ); input = _seriesDavEventInput( baselineRawIcs: baselineRawIcs, uid: uid, existing: existing, desired: input, + seriesTiming: seriesTiming, ); patch = buildDavEventUpdatePatch( target: IcalComponentKey(componentType: 'VEVENT', uid: uid), @@ -4093,6 +4645,14 @@ class CalendarRepository { input: input, timingOnly: timingOnly, ); + if (patch != null && seriesTiming != null) { + patch = reanchorDavSeriesExceptions( + patch: patch, + baselineRawIcs: baselineRawIcs, + change: seriesTiming, + preserveDuration: preserveDuration, + ); + } } else if (recurringScope == RecurringEventMutationScope.thisAndFuture || (recurringScope == RecurringEventMutationScope.singleOccurrence && existing.recurrenceIdKey == null)) { @@ -4777,10 +5337,35 @@ class CalendarRepository { required String accountId, required String providerCalendarId, required String providerEventId, + }) => _markRemovedEventDeleted( + accountId: accountId, + provider: BusyProvider.microsoft, + providerCalendarId: providerCalendarId, + providerEventId: providerEventId, + ); + + /// Google deleted resources may only carry an ID, without the cached + /// occurrence's originalStartTime. Retire that scoped identity in place. + Future markGoogleRemovedEventDeleted({ + required String accountId, + required String providerCalendarId, + required String providerEventId, + }) => _markRemovedEventDeleted( + accountId: accountId, + provider: BusyProvider.google, + providerCalendarId: providerCalendarId, + providerEventId: providerEventId, + ); + + Future _markRemovedEventDeleted({ + required String accountId, + required BusyProvider provider, + required String providerCalendarId, + required String providerEventId, }) async { final scopedSourceId = sourceId( accountId: accountId, - provider: BusyProvider.microsoft, + provider: provider, providerCalendarId: providerCalendarId, ); await _database.transaction(() async { @@ -4789,14 +5374,16 @@ class CalendarRepository { (row) => row.accountId.equals(accountId) & row.calendarSourceId.equals(scopedSourceId) & - row.provider.equals(BusyProvider.microsoft.storageValue) & + row.provider.equals(provider.storageValue) & row.providerEventId.equals(providerEventId) & row.isDeleted.equals(false), )) .get(); if (matches.isEmpty) return; if (matches.length != 1) { - throw StateError('Ambiguous Microsoft event removal identity.'); + throw StateError( + 'Ambiguous ${provider.displayName} event removal identity.', + ); } final match = matches.single; if (match.syncStatus != 'synced') return; @@ -5117,11 +5704,141 @@ DavEventMutationInput _davEventInput( ); } +DateTime? _rawSeriesWall( + Map raw, + String field, + String? seriesZone, +) { + final temporal = _jsonObjectMap(raw[field]); + final date = temporal['date']?.toString(); + if (date != null) return providerCivilDateTime(DateTime.parse(date)); + final instant = providerDateTimeAsUtcInstant( + temporal['dateTime']?.toString(), + temporal['timeZone']?.toString() ?? seriesZone, + ); + return instant == null + ? null + : providerCivilDateTime(providerInstantInTimeZone(instant, seriesZone)); +} + +SeriesTimingChange? _cloudSeriesTimingChange( + CalendarEvent existing, + CalendarEvent master, + EventEditorDraft draft, { + bool shiftExceptions = false, +}) { + final before = _storedEventStartDateTime(existing); + final after = draft.start; + if (before == null || + after == null || + (providerCivilDateTime(before) == providerCivilDateTime(after) && + draft.allDay == existing.allDay && + draft.startTimeZone == existing.startTimeZone && + draft.end != null && + _storedEventEndDateTime(existing) != null && + providerCivilDateTime(draft.end!) == + providerCivilDateTime(_storedEventEndDateTime(existing)!))) { + return null; + } + final recurrence = _decodeStoredJson(master.recurrenceJson); + final masterStart = _storedEventStartDateTime(master); + if (recurrence == null || masterStart == null) { + throw StateError('The recurrence master is incomplete.'); + } + return SeriesTimingChange.plan( + provider: BusyProviderCodec.requireStorageValue(existing.provider), + original: recurrence, + masterStart: masterStart, + occurrenceStart: before, + desiredStart: after, + allDay: draft.allDay, + originalTimeZone: master.startTimeZone, + timeZone: draft.startTimeZone ?? master.startTimeZone, + shiftExceptions: shiftExceptions, + ); +} + +SeriesTimingChange? _davSeriesTimingChange( + String raw, + CalendarEvent existing, + DateTime desiredStart, + DateTime desiredEnd, + bool allDay, + String? timeZone, +) { + final before = _storedEventStartDateTime(existing); + if (before == null || + (providerCivilDateTime(before) == providerCivilDateTime(desiredStart) && + allDay == existing.allDay && + timeZone == existing.startTimeZone && + _storedEventEndDateTime(existing) != null && + providerCivilDateTime(desiredEnd) == + providerCivilDateTime(_storedEventEndDateTime(existing)!))) { + return null; + } + final document = IcalSemanticDocument.parse(raw); + final master = document.components.singleWhere( + (component) => + component.componentType == 'VEVENT' && + component.uid == existing.icalUid && + component.recurrenceId == null, + ); + final resolver = IcalTimeZoneResolver.fromDocument(document); + final masterZone = _icalTimeZone(master.start!); + final destinationZone = timeZone ?? masterZone; + return SeriesTimingChange.plan( + provider: BusyProviderCodec.requireStorageValue(existing.provider), + original: { + 'rules': master.recurrenceRules, + 'dates': master.recurrenceDates, + 'excludedDates': master.exceptionDates, + }, + masterStart: master.start!.localValue, + occurrenceStart: before, + desiredStart: desiredStart, + allDay: allDay, + originalTimeZone: masterZone, + timeZone: destinationZone, + civilToUtc: (wall) => resolver.toUtc( + IcalTemporalValue( + rawValue: '', + kind: destinationZone != null + ? IcalTemporalKind.tzidDateTime + : master.start!.kind, + localValue: wall, + timeZoneId: destinationZone, + ), + ), + originalCivilToUtc: (wall) => resolver.toUtc( + IcalTemporalValue( + rawValue: '', + kind: master.start!.kind, + localValue: wall, + timeZoneId: masterZone, + ), + ), + utcToCivil: (instant) => + masterZone == null ? instant : resolver.fromUtc(instant, masterZone), + ); +} + +void _requireSeriesTimingApproval( + EventEditorDraft draft, + SeriesTimingChange? change, +) { + if (change != null && + change.requiresConfirmation && + draft.seriesTimingApproval != change.signature) { + throw SeriesTimingConfirmationRequired(change); + } +} + DavEventMutationInput _seriesDavEventInput({ required String baselineRawIcs, required String uid, required CalendarEvent existing, required DavEventMutationInput desired, + SeriesTimingChange? seriesTiming, }) { final semantic = IcalSemanticDocument.parse(baselineRawIcs); final masters = semantic.components.where( @@ -5134,9 +5851,20 @@ DavEventMutationInput _seriesDavEventInput({ throw StateError('The DAV recurrence master is unavailable.'); } final master = masters.single; + final resolver = IcalTimeZoneResolver.fromDocument(semantic); final masterStart = _editableIcalTemporal(master.start!); final masterEnd = master.end == null - ? masterStart.add(master.duration?.duration ?? const Duration(hours: 1)) + ? seriesTiming != null && master.duration != null + ? _editableIcalTemporal( + addIcalDuration( + master.start!, + master.duration!, + resolver: resolver, + ), + ) + : masterStart.add( + master.duration?.duration ?? const Duration(hours: 1), + ) : _editableIcalTemporal(master.end!); var projectedStart = DateTime.tryParse( existing.allDay ? existing.startDate ?? '' : existing.startDateTime ?? '', @@ -5147,7 +5875,6 @@ DavEventMutationInput _seriesDavEventInput({ if (projectedStart == null || projectedEnd == null) { throw StateError('The DAV occurrence projection is incomplete.'); } - final resolver = IcalTimeZoneResolver.fromDocument(semantic); if (!existing.allDay) { if (existing.startTimeZone case final zone?) { projectedStart = resolver.fromUtc( @@ -5169,10 +5896,19 @@ DavEventMutationInput _seriesDavEventInput({ final startChanged = startDelta != Duration.zero; final endChanged = endDelta != Duration.zero; final allDayChanged = desired.allDay != existing.allDay; - final seriesStart = startChanged + final seriesStart = seriesTiming != null + ? providerCivilDateTime(seriesTiming.masterStart) + : startChanged ? providerCivilDateTime(masterStart).add(startDelta) : masterStart; - final seriesEnd = endChanged + final seriesEnd = seriesTiming != null + ? seriesStart.add( + providerCivilDateTime(masterEnd) + .difference(providerCivilDateTime(masterStart)) + + endDelta - + startDelta, + ) + : endChanged ? providerCivilDateTime(masterEnd).add(endDelta) : masterEnd; final masterTimeZone = _icalTimeZone(master.start!); @@ -5186,10 +5922,14 @@ DavEventMutationInput _seriesDavEventInput({ allDay: allDayChanged ? desired.allDay : master.start!.isDate, start: seriesStart, end: seriesEnd, - startTimeZone: startChanged || allDayChanged + startTimeZone: seriesTiming != null + ? seriesTiming.timeZone + : startChanged || allDayChanged ? desired.startTimeZone : masterTimeZone, - endTimeZone: endChanged || allDayChanged + endTimeZone: seriesTiming != null && masterEndTimeZone == masterTimeZone + ? seriesTiming.timeZone + : endChanged || allDayChanged ? desired.endTimeZone : masterEndTimeZone, description: (desired.description ?? '') != (existing.description ?? '') @@ -5202,8 +5942,14 @@ DavEventMutationInput _seriesDavEventInput({ ? desired.locationPoint : master.locationPoint, locationChanged: desired.locationChanged, - recurrence: desired.recurrence, - recurrenceChanged: desired.recurrenceChanged, + recurrence: seriesTiming == null + ? desired.recurrence + : seriesTiming.encode({ + 'rules': master.recurrenceRules, + 'dates': master.recurrenceDates, + 'excludedDates': master.exceptionDates, + }), + recurrenceChanged: seriesTiming != null || desired.recurrenceChanged, attendees: desired.attendees, attendeesChanged: desired.attendeesChanged, organizerAddress: desired.organizerAddress, @@ -5597,6 +6343,14 @@ Map _eventRequest( String? _eventDateTimeWireValue(DateTime? value, String? timeZone) { if (value == null) return null; + if (value is time_zone.TZDateTime && value.location.name == timeZone) { + // Calculated import endpoints can land in the second autumn hour. A + // civil string alone would resolve to the first occurrence of that hour. + return value.toIso8601String().replaceFirstMapped( + RegExp(r'([+-]\d{2})(\d{2})$'), + (match) => '${match[1]}:${match[2]}', + ); + } if (isUtcTimeZone(timeZone)) { return DateTime.utc( value.year, diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index 79716c92..ba1d424e 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -26,6 +26,7 @@ import 'package:busymax/src/providers/busy_provider.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../recurrence/domain/event_recurrence_codec.dart'; import '../../recurrence/domain/recurrence_rule.dart'; +import '../../recurrence/domain/series_timing_change.dart'; import '../../recurrence/presentation/recurrence_editor.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; import '../../maps/domain/location_result.dart'; @@ -129,6 +130,62 @@ Future _completeEventEditorSave( required List sources, required List accounts, }) async { + try { + final seriesScope = draft.recurringMutationScope; + final change = + draft.eventId != null && + (seriesScope == RecurringEventMutationScope.entireSeries || + seriesScope == RecurringEventMutationScope.thisAndFuture) + ? await ProviderScope.containerOf( + context, + listen: false, + ).read(calendarRepositoryProvider).prepareSeriesTimingChange(draft) + : null; + if (!context.mounted) return; + if (change?.requiresConfirmation == true) { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.repeat, + message: context.l10n.seriesPatternChangeConfirmation( + recurrenceRuleSummary( + context, + change!.previewRule( + following: + draft.recurringMutationScope == + RecurringEventMutationScope.thisAndFuture, + original: true, + ), + timeZone: change.timeZone, + ), + recurrenceRuleSummary( + context, + change.previewRule( + following: + draft.recurringMutationScope == + RecurringEventMutationScope.thisAndFuture, + ), + timeZone: change.timeZone, + ), + ), + confirmLabel: context.l10n.save, + ); + if (!confirmed || !context.mounted) return; + draft = draft.copyWith(seriesTimingApproval: change.signature); + } + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + error is SeriesTimingDecisionRequired + ? error.message + : context.l10n.operationFailed, + ), + ), + ); + } + return; + } final move = _eventMoveContext( initialDraft: initialDraft, draft: draft, diff --git a/lib/src/features/calendar/presentation/event_editor_draft.dart b/lib/src/features/calendar/presentation/event_editor_draft.dart index 201ab064..2ed9b3aa 100644 --- a/lib/src/features/calendar/presentation/event_editor_draft.dart +++ b/lib/src/features/calendar/presentation/event_editor_draft.dart @@ -165,6 +165,7 @@ class EventEditorDraft { this.googleStatusProperties = const {}, this.googleStatusChanged = false, this.recurringMutationScope, + this.seriesTimingApproval, this.start, this.end, this.startTimeZone, @@ -391,6 +392,7 @@ class EventEditorDraft { final Map googleStatusProperties; final bool googleStatusChanged; final RecurringEventMutationScope? recurringMutationScope; + final String? seriesTimingApproval; final String accountId; final String sourceId; final String providerCalendarId; @@ -581,6 +583,7 @@ class EventEditorDraft { bool? isOrganizer, bool? canManageAttendees, RecurringEventMutationScope? recurringMutationScope, + String? seriesTimingApproval, bool clearLocation = false, bool clearDescription = false, bool clearRecurrence = false, @@ -674,6 +677,7 @@ class EventEditorDraft { recurringMutationScope: clearRecurringMutationScope ? null : recurringMutationScope ?? this.recurringMutationScope, + seriesTimingApproval: seriesTimingApproval ?? this.seriesTimingApproval, accountId: accountId ?? this.accountId, sourceId: sourceId ?? this.sourceId, providerCalendarId: providerCalendarId ?? this.providerCalendarId, @@ -744,6 +748,7 @@ class EventEditorDraft { other.googleStatusProperties == googleStatusProperties && other.googleStatusChanged == googleStatusChanged && other.recurringMutationScope == recurringMutationScope && + other.seriesTimingApproval == seriesTimingApproval && other.accountId == accountId && other.sourceId == sourceId && other.providerCalendarId == providerCalendarId && @@ -793,6 +798,7 @@ class EventEditorDraft { googleStatusProperties, googleStatusChanged, recurringMutationScope, + seriesTimingApproval, accountId, sourceId, providerCalendarId, diff --git a/lib/src/features/notifications/due_today_notification_scheduler.dart b/lib/src/features/notifications/due_today_notification_scheduler.dart index 56b2f22b..3a2d0d48 100644 --- a/lib/src/features/notifications/due_today_notification_scheduler.dart +++ b/lib/src/features/notifications/due_today_notification_scheduler.dart @@ -152,7 +152,7 @@ class DueTodayNotificationScheduler { if (task.status == 'completed' || task.status == 'cancelled') { return false; } - final due = taskDueAsLocal(task); + final due = taskDeadlineAsLocal(task); return due != null && _date(due) == today; }).length; if (count == 0) return; diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index 346adde1..0269251c 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -190,6 +190,7 @@ class NotificationScheduleService { )) .get(); for (final task in rows) { + if (!isVisibleTaskProjection(task)) continue; if (!reminderEnabledTaskListIds.contains(task.taskListId)) { continue; } diff --git a/lib/src/features/recurrence/domain/recurrence_rule.dart b/lib/src/features/recurrence/domain/recurrence_rule.dart index 6bfcc0a5..de267914 100644 --- a/lib/src/features/recurrence/domain/recurrence_rule.dart +++ b/lib/src/features/recurrence/domain/recurrence_rule.dart @@ -455,9 +455,15 @@ _NormalizedEditorRule? _normalizeEditorRule({ defaultMonthDay: baseDate?.day, ); case RecurrenceFrequency.yearly: - final months = byMonth.isEmpty && baseDate != null - ? [baseDate.month] - : byMonth; + // Explicit month days expand throughout the year. An anniversary + // without selectors still inherits its month from DTSTART. + final months = byMonth.isNotEmpty + ? byMonth + : byMonthDay.isNotEmpty + ? List.generate(12, (index) => index + 1) + : baseDate == null + ? const [] + : [baseDate.month]; if (months.isEmpty) return null; return _normalizeMonthlyOrYearly( byDay: byDay, diff --git a/lib/src/features/recurrence/domain/series_timing_change.dart b/lib/src/features/recurrence/domain/series_timing_change.dart new file mode 100644 index 00000000..0907de0d --- /dev/null +++ b/lib/src/features/recurrence/domain/series_timing_change.dart @@ -0,0 +1,911 @@ +import 'dart:convert'; + +import '../../../core/time/provider_date_time.dart'; +import '../../../dav/ical/ical_recurrence.dart'; +import '../../../dav/ical/ical_document.dart'; +import '../../../dav/ical/ical_semantics.dart'; +import '../../../providers/busy_provider.dart'; +import 'event_recurrence_codec.dart'; +import 'recurrence_rule.dart'; + +/// Recurrence positions and the exception timing policy of a series edit. +final class SeriesTimingChange { + SeriesTimingChange._({ + required this.provider, + required this.originalRule, + required this.rule, + required this.originalMasterStart, + required this.masterStart, + required this.originalOccurrenceStart, + required this.desiredStart, + required this.requiresConfirmation, + required this.allDay, + required this.timeZone, + this.shiftExceptions = false, + String? savedSignature, + this.civilToUtc, + this.utcToCivil, + this.originalCivilToUtc, + this.originalTimeZone, + this.monthDayMapping = const {}, + }) : _savedSignature = savedSignature; + + factory SeriesTimingChange.plan({ + required BusyProvider provider, + required Object original, + required DateTime masterStart, + required DateTime occurrenceStart, + required DateTime desiredStart, + required bool allDay, + required String? originalTimeZone, + required String? timeZone, + bool shiftExceptions = false, + DateTime Function(DateTime)? civilToUtc, + DateTime Function(DateTime)? utcToCivil, + DateTime Function(DateTime)? originalCivilToUtc, + }) { + final before = _wall(occurrenceStart); + final after = _wall(desiredStart); + final master = _wall(masterStart); + final dateChanged = _date(before) != _date(after); + final originalRule = EventRecurrenceCodec.decode( + provider, + provider == BusyProvider.microsoft + ? original + : original is Map + ? {'rules': original['rules']} + : original is List + ? original + .where( + (line) => line.toString().toUpperCase().startsWith('RRULE:'), + ) + .toList() + : original, + baseDate: master, + ); + if (!originalRule.isSupported && !dateChanged) { + if (before != after && + originalRule.rawRules.any( + (rule) => + RegExp(r'(?:^|;)(?:BYHOUR|BYMINUTE|BYSECOND)=') + .hasMatch(rule.toUpperCase()), + )) { + throw const SeriesTimingDecisionRequired( + 'The explicit recurrence times need a decision before changing the series time.', + ); + } + return SeriesTimingChange._( + provider: provider, + originalRule: originalRule, + rule: originalRule, + originalMasterStart: master, + masterStart: master.add(after.difference(before)), + originalOccurrenceStart: before, + desiredStart: after, + requiresConfirmation: false, + allDay: allDay, + timeZone: timeZone, + shiftExceptions: shiftExceptions, + originalTimeZone: originalTimeZone, + civilToUtc: civilToUtc, + utcToCivil: utcToCivil, + ); + } + if (!originalRule.isSupported || !originalRule.repeats) { + throw const SeriesTimingDecisionRequired( + 'This recurrence needs an explicit pattern before its dates can change.', + ); + } + var rule = originalRule; + var adjusted = master.add(after.difference(before)); + var confirm = false; + var monthDayMapping = {}; + if (dateChanged) { + switch (rule.frequency) { + case RecurrenceFrequency.none: + case RecurrenceFrequency.daily: + break; + case RecurrenceFrequency.weekly: + final days = _date(after).difference(_date(before)).inDays; + String shift(String day) => + rfcWeekdays[(rfcWeekdays.indexOf(day) + days) % 7]; + rule = rule.copyWith( + byDay: rule.byDay.map(shift).toList(), + // Moving the week boundary as well preserves the phase of an + // interval > 1 when a weekday set wraps into another week. + weekStart: rule.interval > 1 + ? shift(rule.weekStart) + : rule.weekStart, + ); + case RecurrenceFrequency.monthly: + case RecurrenceFrequency.yearly: + final monthShift = + (after.year - before.year) * 12 + after.month - before.month; + if (rule.byMonthDay.isNotEmpty) { + if (rule.byMonthDay.length == 1) { + final oldDay = rule.byMonthDay.single; + final last = DateTime.utc(after.year, after.month + 1, 0).day; + final endRelative = after.day - last - 1; + final newDay = oldDay < 0 && endRelative == oldDay + ? oldDay + : after.day; + rule = rule.copyWith(byMonthDay: [newDay]); + monthDayMapping = {oldDay: newDay}; + // Different month lengths can add/remove dates, even though the + // editor changed only one displayed occurrence. + confirm = + oldDay != newDay && + (oldDay.abs() > 28 || + newDay.abs() > 28 || + oldDay.sign != newDay.sign); + } else { + final last = DateTime.utc(before.year, before.month + 1, 0).day; + final selected = rule.byMonthDay.where( + (day) => (day < 0 ? last + day + 1 : day) == before.day, + ); + if (selected.length != 1) { + throw const SeriesTimingDecisionRequired( + 'Choose which monthly date should change before saving.', + ); + } + rule = rule.copyWith( + byMonthDay: [ + for (final day in rule.byMonthDay) + day == selected.single ? after.day : day, + ], + ); + monthDayMapping = { + for (final day in originalRule.byMonthDay) + day: day == selected.single ? after.day : day, + }; + if (rule.byMonthDay.toSet().length != rule.byMonthDay.length) { + throw const SeriesTimingDecisionRequired( + 'Two monthly dates would merge. Choose their recurrence identities explicitly.', + ); + } + confirm = true; + } + } else { + final weekday = rfcWeekdays[after.weekday - 1]; + final last = DateTime.utc(after.year, after.month + 1, 0).day; + final positive = (after.day - 1) ~/ 7 + 1; + final negative = -((last - after.day) ~/ 7 + 1); + final oldPosition = rule.bySetPosition!; + final position = oldPosition < 0 ? negative : positive; + final supported = EventRecurrenceCodec.limitsFor(provider) + .ordinalPositions; + final proposedPosition = supported.contains(position) + ? position + : supported.contains(positive) + ? positive + : negative; + if (!supported.contains(proposedPosition)) { + throw const SeriesTimingDecisionRequired( + 'Choose a recurrence position supported by this calendar.', + ); + } + rule = rule.copyWith( + byDay: [weekday], + bySetPosition: proposedPosition, + ); + confirm = + oldPosition != proposedPosition || + originalRule.byDay.length != 1; + } + if (rule.frequency == RecurrenceFrequency.yearly) { + rule = rule.copyWith( + byMonth: [ + for (final month in rule.byMonth) + (month - 1 + monthShift) % 12 + 1, + ], + ); + confirm = + confirm || + originalRule.byMonth.length > 1 && monthShift != 0 || + (originalRule.byMonth.contains(2) || + rule.byMonth.contains(2)) && + (originalRule.byMonthDay.contains(29) || + rule.byMonthDay.contains(29)); + } + final month = DateTime.utc(master.year, master.month + monthShift); + final prototype = DateTime.utc( + month.year, + month.month, + 1, + after.hour, + after.minute, + after.second, + ); + final oldCandidates = _candidates(originalRule, master, 0); + final oldIndex = oldCandidates.indexOf(master); + final candidates = _candidates(rule, prototype, 0); + if (oldIndex < 0) { + throw const SeriesTimingDecisionRequired( + 'The new pattern cannot identify the first occurrence safely.', + ); + } + if (monthDayMapping.isNotEmpty) { + final selector = _monthDaySelectors(originalRule, master); + if (selector.length != 1) { + throw const SeriesTimingDecisionRequired( + 'The first occurrence has an ambiguous monthly identity.', + ); + } + final date = _monthDay( + month.year, + month.month, + monthDayMapping[selector.single]!, + ); + if (date != null) { + adjusted = DateTime.utc( + date.year, + date.month, + date.day, + after.hour, + after.minute, + after.second, + ); + } else { + DateTime? first; + for ( + var period = 0; + period < const IcalRecurrenceLimits().maximumPeriods && + first == null; + period++ + ) { + first = _candidates(rule, prototype, period).firstOrNull; + } + if (first == null) { + throw const SeriesTimingDecisionRequired( + 'The revised pattern has no first occurrence within the supported expansion limit.', + ); + } + adjusted = first; + } + if (before.year == master.year && + before.month == master.month && + after.isBefore(adjusted)) { + adjusted = after; + } + } else { + if (oldIndex >= candidates.length) { + throw const SeriesTimingDecisionRequired( + 'The revised ordinal cannot identify the first occurrence safely.', + ); + } + adjusted = candidates[oldIndex]; + } + } + } + final until = rule.untilRaw?.endsWith('Z') == true && utcToCivil != null + ? _isoDate(utcToCivil(DateTime.parse(rule.untilRaw!))) + : rule.untilDateFor(timeZone: originalTimeZone); + if (until != null) { + if (!allDay && timeZone != null && civilToUtc != null) { + final day = DateTime.parse(until); + rule = rule.copyWith( + untilRaw: _compact( + civilToUtc( + DateTime.utc( + day.year, + day.month, + day.day, + adjusted.hour, + adjusted.minute, + adjusted.second, + ), + ), + utc: true, + ), + ); + } else { + rule = rule.withUntilDate( + until, + allDay: allDay, + floating: !allDay && timeZone == null, + baseDate: adjusted, + timeZone: timeZone, + ); + } + } + if (!EventRecurrenceCodec.canEncode(provider, rule)) { + throw const SeriesTimingDecisionRequired( + 'Choose a recurrence pattern supported by this calendar.', + ); + } + final result = SeriesTimingChange._( + provider: provider, + originalRule: originalRule, + rule: rule, + originalMasterStart: master, + masterStart: adjusted, + originalOccurrenceStart: before, + desiredStart: after, + requiresConfirmation: confirm, + allDay: allDay, + timeZone: timeZone, + shiftExceptions: shiftExceptions, + originalTimeZone: originalTimeZone, + civilToUtc: civilToUtc, + utcToCivil: utcToCivil, + originalCivilToUtc: originalCivilToUtc, + monthDayMapping: monthDayMapping, + ); + if (result.mapOccurrence(before) != after) { + throw const SeriesTimingDecisionRequired( + 'The edited date cannot be associated with the revised pattern safely.', + ); + } + if (dateChanged && !result.containsNewOccurrence(after)) { + throw const SeriesTimingDecisionRequired( + 'The edited date is outside the series ending condition. Choose its ending condition explicitly.', + ); + } + return result; + } + + final BusyProvider provider; + final RecurrenceRule originalRule; + final RecurrenceRule rule; + final DateTime originalMasterStart; + final DateTime masterStart; + final DateTime originalOccurrenceStart; + final DateTime desiredStart; + final bool requiresConfirmation; + final bool allDay; + + /// Cloud pointer rescheduling retains its delta-based exception behavior. + /// Ordinary editor saves preserve an individually rescheduled interval. + final bool shiftExceptions; + final String? timeZone; + final String? originalTimeZone; + final DateTime Function(DateTime)? civilToUtc; + final DateTime Function(DateTime)? utcToCivil; + final DateTime Function(DateTime)? originalCivilToUtc; + final Map monthDayMapping; + final String? _savedSignature; + + Map toJson() => { + 'provider': provider.storageValue, + 'beforeRule': originalRule.isSupported ? originalRule.toRrule() : null, + 'afterRule': rule.isSupported ? rule.toRrule() : null, + 'beforeStart': originalMasterStart.toIso8601String(), + 'afterStart': masterStart.toIso8601String(), + 'occurrenceStart': originalOccurrenceStart.toIso8601String(), + 'desiredStart': desiredStart.toIso8601String(), + 'allDay': allDay, + 'shiftExceptions': shiftExceptions, + 'timeZone': timeZone, + 'originalTimeZone': originalTimeZone, + 'confirmation': requiresConfirmation, + 'signature': signature, + 'monthDayMapping': { + for (final entry in monthDayMapping.entries) '${entry.key}': entry.value, + }, + if (!originalRule.isSupported) + 'opaque': { + 'rules': originalRule.rawRules, + 'dates': originalRule.recurrenceDates, + 'excludedDates': originalRule.exceptionDates, + }, + }; + + factory SeriesTimingChange.fromJson(Map json) { + final before = DateTime.parse(json['beforeStart'] as String); + final after = DateTime.parse(json['afterStart'] as String); + return SeriesTimingChange._( + provider: BusyProviderCodec.requireStorageValue( + json['provider'] as String, + ), + originalRule: json['opaque'] is Map + ? const RecurrenceRule.unsupported() + : RecurrenceRule.fromIcalendar( + rules: [json['beforeRule'] as String], + baseDate: before, + ), + rule: json['opaque'] is Map + ? const RecurrenceRule.unsupported() + : RecurrenceRule.fromIcalendar( + rules: [json['afterRule'] as String], + baseDate: after, + ), + originalMasterStart: before, + masterStart: after, + originalOccurrenceStart: DateTime.parse( + json['occurrenceStart'] as String, + ), + desiredStart: DateTime.parse(json['desiredStart'] as String), + requiresConfirmation: json['confirmation'] == true, + allDay: json['allDay'] == true, + shiftExceptions: json['shiftExceptions'] == true, + timeZone: json['timeZone'] as String?, + originalTimeZone: json['originalTimeZone'] as String?, + savedSignature: json['signature'] as String?, + monthDayMapping: { + for (final entry in (json['monthDayMapping'] as Map).entries) + int.parse(entry.key as String): entry.value as int, + }, + ); + } + + String get signature => + _savedSignature ?? + jsonEncode([ + provider.storageValue, + originalRule.isSupported + ? originalRule.toRrule() + : originalRule.rawRules, + rule.isSupported ? rule.toRrule() : rule.rawRules, + originalMasterStart.toIso8601String(), + masterStart.toIso8601String(), + originalOccurrenceStart.toIso8601String(), + desiredStart.toIso8601String(), + allDay, + if (shiftExceptions) 'shiftExceptions', + timeZone, + [ + for (final entry in monthDayMapping.entries) [entry.key, entry.value], + ], + ]); + + /// Resolve timing separately from the presence of non-timing overrides. + /// [baselineStart] and [baselineEnd] are authoritative values in the series + /// zone; [start] and [end] can include pending local edits. Both projection + /// and replay use this decision, and replay persists its resolved payload. + ({DateTime start, DateTime end, bool preserved})? occurrenceTiming({ + required DateTime originalStart, + required DateTime start, + required DateTime end, + required DateTime? baselineStart, + required DateTime? baselineEnd, + required Duration? masterDuration, + required Duration endDelta, + bool pendingTimingEdit = false, + bool startChanged = true, + bool endChanged = true, + }) { + final rescheduled = + pendingTimingEdit || + baselineStart != null && + (_wall(baselineStart) != _wall(originalStart) || + baselineEnd != null && + masterDuration != null && + baselineEnd.difference(baselineStart) != masterDuration); + if (rescheduled && !shiftExceptions) { + return (start: start, end: end, preserved: true); + } + final startDelta = desiredStart.difference(originalOccurrenceStart); + final mapped = !startChanged + ? start + : rescheduled + ? start.add(startDelta) + : mapOccurrence(start); + if (mapped == null) return null; + return ( + start: mapped, + end: !endChanged + ? end + : rescheduled + ? end.add(endDelta) + : mapped.add(end.difference(start) + endDelta - startDelta), + preserved: false, + ); + } + + Object encode(Object original) { + if (!rule.isSupported) return original; + final encoded = EventRecurrenceCodec.encode( + provider, + rule, + baseDate: masterStart, + allDay: allDay, + timeZone: timeZone, + original: original, + )!; + if (provider != BusyProvider.google) return encoded; + return [ + for (final line in encoded as List) + if (!line.toString().toUpperCase().startsWith('EXDATE')) + line + else + ...mapExclusions( + IcalDocument.parse( + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:exclusion\r\n$line\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n', + ).root.components.single.firstProperty('EXDATE')!, + ).map((property) => property.serializeLogicalLine()), + ]; + } + + List mapExclusions( + IcalProperty source, { + DateTime Function(IcalTemporalValue)? asCivil, + }) { + final result = []; + for (final token in source.rawValue.split(',')) { + final property = source.deepCopy()..rawValue = token; + final temporal = parseIcalTemporal(property)!; + final before = + asCivil?.call(temporal) ?? + switch (temporal.kind) { + IcalTemporalKind.date || + IcalTemporalKind.floatingDateTime => temporal.localValue, + IcalTemporalKind.utcDateTime => providerInstantInTimeZone( + temporal.localValue, + originalTimeZone ?? timeZone, + ), + IcalTemporalKind.tzidDateTime => providerInstantInTimeZone( + providerWallTimeToInstant( + temporal.localValue, + temporal.timeZoneId, + ), + originalTimeZone ?? timeZone, + ), + }; + if (originalRule.isSupported && + !containsNewOccurrence( + before, + recurrence: originalRule.copyWith(count: null, untilRaw: null), + startingAt: originalMasterStart, + )) { + if (_date(originalOccurrenceStart) != _date(desiredStart)) { + throw const SeriesTimingDecisionRequired( + 'An exclusion outside the regular pattern needs an explicit recurrence decision.', + ); + } + result.add(property); + continue; + } + final mapped = mapOccurrence(before); + if (mapped == null || !containsNewOccurrence(mapped)) { + throw const SeriesTimingDecisionRequired( + 'A cancellation cannot be associated with the revised pattern safely.', + ); + } + final utc = temporal.kind == IcalTemporalKind.utcDateTime && !allDay; + final value = utc + ? (civilToUtc ?? (wall) => providerWallTimeToInstant(wall, timeZone))( + mapped, + ) + : mapped; + result.add( + IcalProperty( + group: source.group, + name: source.name, + parameters: [ + ...source.parameters.where( + (parameter) => + parameter.name != 'VALUE' && parameter.name != 'TZID', + ), + if (allDay) + const IcalParameter( + name: 'VALUE', + values: ['DATE'], + wasQuoted: false, + ), + if (!allDay && !utc && timeZone != null) + IcalParameter(name: 'TZID', values: [timeZone!], wasQuoted: true), + ], + rawValue: allDay + ? _compact(value).substring(0, 8) + : _compact(value, utc: utc), + originalPhysicalLines: const [], + isDirty: true, + ), + ); + } + return result; + } + + DateTime? mapOccurrence(DateTime original) { + final value = _wall(original); + final anchor = originalMasterStart; + if (!originalRule.isSupported || + originalRule.frequency == RecurrenceFrequency.daily || + originalRule.frequency == RecurrenceFrequency.weekly) { + return value.add(masterStart.difference(anchor)); + } + if (monthDayMapping.isNotEmpty) { + final selectors = _monthDaySelectors(originalRule, value); + if (selectors.length != 1) return null; + final months = + (desiredStart.year - originalOccurrenceStart.year) * 12 + + desiredStart.month - + originalOccurrenceStart.month; + final targetMonth = DateTime.utc(value.year, value.month + months); + final target = _monthDay( + targetMonth.year, + targetMonth.month, + monthDayMapping[selectors.single]!, + ); + if (target == null) return null; + return DateTime.utc( + target.year, + target.month, + target.day, + masterStart.hour, + masterStart.minute, + masterStart.second, + ); + } + final units = originalRule.frequency == RecurrenceFrequency.monthly + ? (value.year - anchor.year) * 12 + value.month - anchor.month + : value.year - anchor.year; + if (units < 0 || units % originalRule.interval != 0) return null; + final period = units ~/ originalRule.interval; + final oldCandidates = _candidates(originalRule, anchor, period); + final index = oldCandidates.indexOf(value); + final candidates = _candidates(rule, masterStart, period); + if (index < 0 || index >= candidates.length) return null; + // With multiple selectors, a changed ordering or a disappearing date + // cannot establish which exception belongs to which new occurrence. + if (oldCandidates.length != candidates.length && + originalRule.byMonthDay.length > 1) { + return null; + } + return candidates[index]; + } + + bool containsOriginalOccurrence(DateTime value) => containsNewOccurrence( + value, + recurrence: originalRule, + startingAt: originalMasterStart, + resolveUtc: + originalCivilToUtc ?? + (wall) => providerWallTimeToInstant(wall, originalTimeZone), + ); + + RecurrenceRule previewRule({required bool following, bool original = false}) { + final value = original ? originalRule : rule; + final count = originalRule.count; + if (!following || count == null) return value; + var preceding = 0; + for ( + var period = 0; + period < const IcalRecurrenceLimits().maximumPeriods; + period++ + ) { + for (final candidate in _candidates( + originalRule, + originalMasterStart, + period, + )) { + if (candidate.isBefore(originalMasterStart)) continue; + if (!candidate.isBefore(originalOccurrenceStart)) { + return value.copyWith(count: count - preceding); + } + preceding++; + if (preceding >= count) { + throw const SeriesTimingDecisionRequired( + 'The edited occurrence is outside the original count.', + ); + } + } + } + throw const SeriesTimingDecisionRequired( + 'The edited occurrence exceeds the supported recurrence expansion limit.', + ); + } + + bool removesOrdinaryOccurrence( + DateTime value, { + RecurrenceRule? recurrence, + DateTime? startingAt, + }) { + final mapped = mapOccurrence(value); + if (mapped != null) { + return !containsNewOccurrence( + mapped, + recurrence: recurrence, + startingAt: startingAt, + ); + } + return monthDayMapping.isNotEmpty && + _monthDaySelectors(originalRule, value).length == 1; + } + + /// RANGE=THISANDFUTURE can express one civil timing difference. Verify the + /// whole finite schedule, or the Gregorian selector cycle for an unlimited + /// rule, before retaining a ranged exception under a new pattern. + bool preservesFollowingOverride() { + if (timeZone != originalTimeZone) return false; + if (!rule.isSupported || !originalRule.isSupported) { + return _date(masterStart) == _date(originalMasterStart); + } + final delta = masterStart.difference(originalMasterStart); + if (originalRule.count != null || originalRule.untilRaw != null) { + final old = _ruleOccurrences( + originalRule, + originalMasterStart, + originalCivilToUtc ?? + (wall) => providerWallTimeToInstant(wall, originalTimeZone), + ).iterator; + final revised = _ruleOccurrences( + rule, + masterStart, + civilToUtc ?? (wall) => providerWallTimeToInstant(wall, timeZone), + ).iterator; + while (old.moveNext()) { + if (!revised.moveNext() || old.current.add(delta) != revised.current) { + return false; + } + } + return true; + } + if (rule.frequency == RecurrenceFrequency.daily || + rule.frequency == RecurrenceFrequency.weekly) { + return true; + } + final periods = rule.frequency == RecurrenceFrequency.monthly ? 4800 : 400; + for (var period = 0; period < periods; period++) { + final old = _candidates(originalRule, originalMasterStart, period) + .where((date) => !date.isBefore(originalMasterStart)) + .map((date) => date.add(delta)) + .toList(); + final revised = _candidates( + rule, + masterStart, + period, + ).where((date) => !date.isBefore(masterStart)).toList(); + if (old.length != revised.length) return false; + for (var index = 0; index < old.length; index++) { + if (old[index] != revised[index]) return false; + } + } + return true; + } + + ({DateTime? previous, DateTime? next}) adjacentNewOccurrences( + DateTime value, + ) { + DateTime? previous; + for (final occurrence in _ruleOccurrences( + rule, + masterStart, + civilToUtc ?? (wall) => providerWallTimeToInstant(wall, timeZone), + )) { + if (occurrence.isAfter(value)) { + return (previous: previous, next: occurrence); + } + if (occurrence.isBefore(value)) previous = occurrence; + } + return (previous: previous, next: null); + } + + bool containsNewOccurrence( + DateTime candidate, { + RecurrenceRule? recurrence, + DateTime? startingAt, + DateTime Function(DateTime)? resolveUtc, + }) { + final pattern = recurrence ?? rule; + final anchor = startingAt ?? masterStart; + if (!pattern.isSupported) return true; + final value = _wall(candidate); + if (value.isBefore(anchor)) return false; + final until = pattern.untilRaw; + if (until != null) { + final limit = DateTime.parse(until); + if (until.length == 8) { + if (_date(value).isAfter(_date(limit))) return false; + } else if ((until.endsWith('Z') + ? (resolveUtc ?? + civilToUtc ?? + (wall) => providerWallTimeToInstant(wall, timeZone))(value) + : value) + .isAfter(until.endsWith('Z') ? limit : _wall(limit))) { + return false; + } + } + final units = switch (pattern.frequency) { + RecurrenceFrequency.none => 0, + RecurrenceFrequency.daily => _date( + value, + ).difference(_date(anchor)).inDays, + RecurrenceFrequency.weekly => + _week( + value, + pattern.weekStart, + ).difference(_week(anchor, pattern.weekStart)).inDays ~/ + 7, + RecurrenceFrequency.monthly => + (value.year - anchor.year) * 12 + value.month - anchor.month, + RecurrenceFrequency.yearly => value.year - anchor.year, + }; + if (units < 0 || units % pattern.interval != 0) return false; + final period = units ~/ pattern.interval; + if (!_candidates(pattern, anchor, period).contains(value)) return false; + final count = pattern.count; + if (count == null) return true; + var generated = 0; + for (var current = 0; current <= period; current++) { + for (final occurrence in _candidates(pattern, anchor, current)) { + if (occurrence.isBefore(anchor)) continue; + generated++; + if (generated > count) return false; + if (occurrence == value) return true; + } + } + return false; + } +} + +final class SeriesTimingDecisionRequired implements Exception { + const SeriesTimingDecisionRequired(this.message); + final String message; + @override + String toString() => message; +} + +final class SeriesTimingConfirmationRequired implements Exception { + const SeriesTimingConfirmationRequired(this.change); + final SeriesTimingChange change; +} + +List _candidates(RecurrenceRule rule, DateTime anchor, int period) => + IcalRecurrenceExpander().rulePeriodCandidates( + recurrenceRule: rule.toRrule(), + anchor: anchor, + period: period, + ); + +DateTime _wall(DateTime value) => DateTime.utc( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.millisecond, + value.microsecond, +); +DateTime _date(DateTime value) => + DateTime.utc(value.year, value.month, value.day); +DateTime _week(DateTime value, String weekStart) => _date(value).subtract( + Duration(days: (value.weekday - rfcWeekdays.indexOf(weekStart) - 1) % 7), +); +String _isoDate(DateTime value) => value.toIso8601String().substring(0, 10); +String _compact(DateTime value, {bool utc = false}) => + '${value.year.toString().padLeft(4, '0')}${value.month.toString().padLeft(2, '0')}${value.day.toString().padLeft(2, '0')}T' + '${value.hour.toString().padLeft(2, '0')}${value.minute.toString().padLeft(2, '0')}${value.second.toString().padLeft(2, '0')}${utc ? 'Z' : ''}'; +List _monthDaySelectors(RecurrenceRule rule, DateTime date) => [ + for (final day in rule.byMonthDay) + if (_monthDay(date.year, date.month, day)?.day == date.day) day, +]; +DateTime? _monthDay(int year, int month, int selector) { + final last = DateTime.utc(year, month + 1, 0).day; + final day = selector > 0 ? selector : last + selector + 1; + return day < 1 || day > last ? null : DateTime.utc(year, month, day); +} + +Iterable _ruleOccurrences( + RecurrenceRule rule, + DateTime anchor, + DateTime Function(DateTime) toUtc, +) sync* { + var generated = 0; + final until = rule.untilRaw == null ? null : DateTime.parse(rule.untilRaw!); + for ( + var period = 0; + period < const IcalRecurrenceLimits().maximumPeriods; + period++ + ) { + for (final candidate in _candidates(rule, anchor, period)) { + if (candidate.isBefore(anchor)) continue; + if (until != null && + (rule.untilRaw!.length == 8 + ? _date(candidate).isAfter(_date(until)) + : (rule.untilRaw!.endsWith('Z') ? toUtc(candidate) : candidate) + .isAfter( + rule.untilRaw!.endsWith('Z') ? until : _wall(until), + ))) { + return; + } + yield candidate; + generated++; + if (rule.count != null && generated >= rule.count!) return; + } + } + throw const SeriesTimingDecisionRequired( + 'The recurrence exceeds the supported expansion limit.', + ); +} diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index ee39c0fe..644c6ffc 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -9,6 +9,7 @@ import '../../../l10n/localized_formatters.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; import '../domain/event_recurrence_codec.dart'; import '../domain/recurrence_rule.dart'; +import 'recurrence_summary.dart' as shared; Future showRecurrenceEditorDialog( BuildContext context, { @@ -44,183 +45,12 @@ String recurrenceRuleSummary( BuildContext context, RecurrenceRule recurrence, { String? timeZone, -}) { - if (!recurrence.repeats) return context.l10n.repeatNone; - final l10n = context.l10n; - if (recurrence.frequency == RecurrenceFrequency.yearly && - recurrence.bySetPosition != null && - recurrence.byMonth.length > 1) { - return l10n.unsupportedRecurrencePreserved; - } - var summary = recurrence.interval == 1 - ? switch (recurrence.frequency) { - RecurrenceFrequency.daily => l10n.repeatDaily, - RecurrenceFrequency.weekly => l10n.repeatWeekly, - RecurrenceFrequency.monthly => l10n.repeatMonthly, - RecurrenceFrequency.yearly => l10n.repeatYearly, - RecurrenceFrequency.none => l10n.repeatNone, - } - : switch (recurrence.frequency) { - RecurrenceFrequency.daily => l10n.repeatEveryDays( - recurrence.interval, - ), - RecurrenceFrequency.weekly => l10n.repeatEveryWeeks( - recurrence.interval, - ), - RecurrenceFrequency.monthly => l10n.repeatEveryMonths( - recurrence.interval, - ), - RecurrenceFrequency.yearly => l10n.repeatEveryYears( - recurrence.interval, - ), - RecurrenceFrequency.none => l10n.repeatNone, - }; - var yearlyMonthPlacedInSummary = false; - if (recurrence.frequency == RecurrenceFrequency.weekly && - recurrence.byDay.isNotEmpty) { - final dayValues = recurrence.byDay - .map((day) => _localizedWeeklyDaySummary(context, day)) - .toList(growable: false); - final days = _localizedRecurrenceList( - dayValues, - pair: l10n.repeatWeekdayListPair, - start: l10n.repeatWeekdayListStart, - ); - summary = - '$summary${l10n.repeatSummarySeparator}${l10n.repeatOnDaysSummary(days)}'; - } else if ((recurrence.frequency == RecurrenceFrequency.monthly || - recurrence.frequency == RecurrenceFrequency.yearly) && - recurrence.byMonthDay.isNotEmpty) { - final monthDayValues = recurrence.byMonthDay - .map( - (day) => l10n.repeatMonthDayValue(_localizedDayNumber(context, day)), - ) - .toList(growable: false); - if (recurrence.frequency == RecurrenceFrequency.yearly && - recurrence.byMonth.isNotEmpty) { - final monthValues = recurrence.byMonth - .map((month) => _localizedYearlyMonth(context, month)) - .toList(growable: false); - final monthDays = _localizedRecurrenceList( - monthDayValues, - pair: l10n.repeatYearlyMonthDayListPair, - start: l10n.repeatYearlyMonthDayListStart, - ); - final months = _localizedRecurrenceList( - monthValues, - pair: l10n.repeatYearlyMonthListPair, - start: l10n.repeatYearlyMonthListStart, - ); - summary = switch ((monthValues.length, monthDayValues.length)) { - (1, 1) => l10n.repeatYearlyOnMonthDaySummary( - summary, - monthValues.single, - monthDayValues.single, - ), - (1, 2) => l10n.repeatYearlyOnTwoMonthDaysSummary( - summary, - monthValues.single, - monthDayValues.first, - monthDayValues.last, - ), - (1, _) => l10n.repeatYearlyOnMonthDaysSummary( - summary, - monthValues.single, - monthDays, - ), - (2, 1) => l10n.repeatYearlyInTwoMonthsOnMonthDaySummary( - summary, - monthValues.first, - monthValues.last, - monthDayValues.single, - ), - (2, 2) => l10n.repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( - summary, - monthValues.first, - monthValues.last, - monthDayValues.first, - monthDayValues.last, - ), - (2, _) => l10n.repeatYearlyInTwoMonthsOnMonthDaysSummary( - summary, - monthValues.first, - monthValues.last, - monthDays, - ), - (_, 1) => l10n.repeatYearlyInMonthsOnMonthDaySummary( - summary, - months, - monthDayValues.single, - ), - _ => l10n.repeatYearlyInMonthsOnMonthDaysSummary( - summary, - months, - monthDays, - ), - }; - yearlyMonthPlacedInSummary = true; - } else { - final monthDays = _localizedRecurrenceList( - monthDayValues, - pair: l10n.repeatMonthDayListPair, - start: l10n.repeatMonthDayListStart, - ); - final monthDaysSummary = switch (monthDayValues.length) { - 1 => l10n.repeatOnMonthDaysSummary(monthDayValues.single), - 2 => l10n.repeatOnTwoMonthDaysSummary( - monthDayValues.first, - monthDayValues.last, - ), - _ => l10n.repeatOnMonthDaysSummaryMultiple(monthDays), - }; - summary = '$summary${l10n.repeatSummarySeparator}$monthDaysSummary'; - } - } else if (recurrence.bySetPosition case final position?) { - final ordinalPosition = _ordinalPositionKey(position); - final days = _localizedOrdinalDaySummary( - context, - recurrence.byDay, - ordinalPosition, - ); - if (recurrence.frequency == RecurrenceFrequency.yearly && - recurrence.byMonth.isNotEmpty) { - final month = _localizedYearlyMonth(context, recurrence.byMonth.single); - summary = l10n.repeatYearlyOnOrdinalSummary( - summary, - month, - ordinalPosition, - days, - ); - yearlyMonthPlacedInSummary = true; - } else { - final ordinalSummary = l10n.repeatOnOrdinalSummary(ordinalPosition, days); - summary = '$summary${l10n.repeatSummarySeparator}$ordinalSummary'; - } - } - if (recurrence.frequency == RecurrenceFrequency.yearly && - recurrence.byMonth.isNotEmpty && - !yearlyMonthPlacedInSummary) { - final monthValues = recurrence.byMonth - .map((month) => _localizedYearlyMonth(context, month)) - .toList(growable: false); - final months = _localizedRecurrenceList( - monthValues, - pair: l10n.repeatYearlyMonthListPair, - start: l10n.repeatYearlyMonthListStart, - ); - summary = '$summary ${l10n.repeatInMonthsSummary(months)}'; - } - if (recurrence.count != null) { - return '$summary · ${l10n.repeatTimesSummary(recurrence.count!)}'; - } - final until = recurrence.untilDateFor(timeZone: timeZone); - if (until == null) return summary; - final date = DateTime.tryParse(until); - final label = date == null - ? until - : MaterialLocalizations.of(context).formatMediumDate(date); - return '$summary · ${l10n.repeatUntilSummary(label)}'; -} +}) => shared.recurrenceRuleSummary( + context, + recurrence, + timeZone: timeZone, + formatDate: MaterialLocalizations.of(context).formatMediumDate, +); class RecurrenceEditorDialog extends StatefulWidget { const RecurrenceEditorDialog({ @@ -735,13 +565,6 @@ String _localizedStandaloneWeekday( return localizedWeekdayLabel(locale, date, abbreviated: abbreviated); } -String _localizedInlineWeekday(BuildContext context, String day) { - final date = _weekdayDate(day); - if (date == null) return day; - final locale = Localizations.localeOf(context).toLanguageTag(); - return localizedInlineWeekday(locale, date); -} - String _localizedStandaloneMonth(BuildContext context, int month) { if (month < 1 || month > 12) return '$month'; final locale = Localizations.localeOf(context).toLanguageTag(); @@ -768,36 +591,6 @@ String _localizedRecurrenceList( return result; } -String _localizedInlineMonth(BuildContext context, int month) { - if (month < 1 || month > 12) return '$month'; - final locale = Localizations.localeOf(context).toLanguageTag(); - return localizedInlineMonth(locale, DateTime(2024, month), abbreviated: true); -} - -String _localizedYearlyMonth(BuildContext context, int month) { - final localizedMonth = _localizedInlineMonth(context, month); - return context.l10n.repeatYearlyMonthValue( - localizedMonth, - _recurrenceMonthKey(month), - ); -} - -String _recurrenceMonthKey(int month) => switch (month) { - 1 => 'jan', - 2 => 'feb', - 3 => 'mar', - 4 => 'apr', - 5 => 'may', - 6 => 'jun', - 7 => 'jul', - 8 => 'aug', - 9 => 'sep', - 10 => 'oct', - 11 => 'nov', - 12 => 'dec', - _ => 'other', -}; - String _localizedOrdinal(BuildContext context, int value) => switch (value) { 1 => context.l10n.repeatFirst, 2 => context.l10n.repeatSecond, @@ -809,17 +602,6 @@ String _localizedOrdinal(BuildContext context, int value) => switch (value) { _ => '$value', }; -String _ordinalPositionKey(int value) => switch (value) { - 1 => 'first', - 2 => 'second', - 3 => 'third', - 4 => 'fourth', - 5 => 'fifth', - -2 => 'secondToLast', - -1 => 'last', - _ => '$value', -}; - String _localizedOrdinalDayChoice(BuildContext context, List days) { if (days.length == 1) { return _localizedStandaloneWeekday( @@ -849,42 +631,6 @@ String _localizedOrdinalDayChoice(BuildContext context, List days) { ); } -String _localizedOrdinalDaySummary( - BuildContext context, - List days, - String ordinalPosition, -) { - final dayKey = _ordinalDayChoiceKey(days); - final ordinalDayKey = Localizations.localeOf(context).languageCode == 'pl' - ? '$ordinalPosition$dayKey' - : dayKey; - if (days.length == 1) { - return context.l10n.repeatOrdinalDaySummary( - ordinalDayKey, - _localizedInlineWeekday(context, days.single), - ); - } - if (dayKey == 'day' || dayKey == 'weekday' || dayKey == 'weekend') { - return context.l10n.repeatOrdinalDaySummary(ordinalDayKey, ''); - } - final dayValues = days - .map((day) => _localizedInlineWeekday(context, day)) - .toList(growable: false); - final localizedDays = _localizedRecurrenceList( - dayValues, - pair: context.l10n.repeatWeekdayListPair, - start: context.l10n.repeatWeekdayListStart, - ); - return context.l10n.repeatOrdinalDaySummary(ordinalDayKey, localizedDays); -} - -String _localizedWeeklyDaySummary(BuildContext context, String day) { - return context.l10n.repeatWeeklyDaySummary( - day, - _localizedInlineWeekday(context, day), - ); -} - bool _sameDaySet(List left, List right) { if (left.length != right.length) return false; final leftValues = {...left}; diff --git a/lib/src/features/recurrence/presentation/recurrence_summary.dart b/lib/src/features/recurrence/presentation/recurrence_summary.dart new file mode 100644 index 00000000..8df767fa --- /dev/null +++ b/lib/src/features/recurrence/presentation/recurrence_summary.dart @@ -0,0 +1,330 @@ +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/l10n.dart'; +import '../../../l10n/localized_formatters.dart'; +import '../domain/recurrence_rule.dart'; +import '../domain/event_recurrence_codec.dart'; + +String recurrenceRuleSummary( + BuildContext context, + RecurrenceRule recurrence, { + String? timeZone, + String Function(DateTime)? formatDate, +}) { + if (!recurrence.repeats) return context.l10n.repeatNone; + final l10n = context.l10n; + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.bySetPosition != null && + recurrence.byMonth.length > 1) { + return l10n.unsupportedRecurrencePreserved; + } + var summary = recurrence.interval == 1 + ? switch (recurrence.frequency) { + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + RecurrenceFrequency.none => l10n.repeatNone, + } + : switch (recurrence.frequency) { + RecurrenceFrequency.daily => l10n.repeatEveryDays( + recurrence.interval, + ), + RecurrenceFrequency.weekly => l10n.repeatEveryWeeks( + recurrence.interval, + ), + RecurrenceFrequency.monthly => l10n.repeatEveryMonths( + recurrence.interval, + ), + RecurrenceFrequency.yearly => l10n.repeatEveryYears( + recurrence.interval, + ), + RecurrenceFrequency.none => l10n.repeatNone, + }; + var yearlyMonthPlacedInSummary = false; + if (recurrence.frequency == RecurrenceFrequency.weekly && + recurrence.byDay.isNotEmpty) { + final dayValues = recurrence.byDay + .map((day) => _localizedWeeklyDaySummary(context, day)) + .toList(growable: false); + final days = _localizedRecurrenceList( + dayValues, + pair: l10n.repeatWeekdayListPair, + start: l10n.repeatWeekdayListStart, + ); + summary = + '$summary${l10n.repeatSummarySeparator}${l10n.repeatOnDaysSummary(days)}'; + } else if ((recurrence.frequency == RecurrenceFrequency.monthly || + recurrence.frequency == RecurrenceFrequency.yearly) && + recurrence.byMonthDay.isNotEmpty) { + final monthDayValues = recurrence.byMonthDay + .map( + (day) => l10n.repeatMonthDayValue(_localizedDayNumber(context, day)), + ) + .toList(growable: false); + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty) { + final monthValues = recurrence.byMonth + .map((month) => _localizedYearlyMonth(context, month)) + .toList(growable: false); + final monthDays = _localizedRecurrenceList( + monthDayValues, + pair: l10n.repeatYearlyMonthDayListPair, + start: l10n.repeatYearlyMonthDayListStart, + ); + final months = _localizedRecurrenceList( + monthValues, + pair: l10n.repeatYearlyMonthListPair, + start: l10n.repeatYearlyMonthListStart, + ); + summary = switch ((monthValues.length, monthDayValues.length)) { + (1, 1) => l10n.repeatYearlyOnMonthDaySummary( + summary, + monthValues.single, + monthDayValues.single, + ), + (1, 2) => l10n.repeatYearlyOnTwoMonthDaysSummary( + summary, + monthValues.single, + monthDayValues.first, + monthDayValues.last, + ), + (1, _) => l10n.repeatYearlyOnMonthDaysSummary( + summary, + monthValues.single, + monthDays, + ), + (2, 1) => l10n.repeatYearlyInTwoMonthsOnMonthDaySummary( + summary, + monthValues.first, + monthValues.last, + monthDayValues.single, + ), + (2, 2) => l10n.repeatYearlyInTwoMonthsOnTwoMonthDaysSummary( + summary, + monthValues.first, + monthValues.last, + monthDayValues.first, + monthDayValues.last, + ), + (2, _) => l10n.repeatYearlyInTwoMonthsOnMonthDaysSummary( + summary, + monthValues.first, + monthValues.last, + monthDays, + ), + (_, 1) => l10n.repeatYearlyInMonthsOnMonthDaySummary( + summary, + months, + monthDayValues.single, + ), + _ => l10n.repeatYearlyInMonthsOnMonthDaysSummary( + summary, + months, + monthDays, + ), + }; + yearlyMonthPlacedInSummary = true; + } else { + final monthDays = _localizedRecurrenceList( + monthDayValues, + pair: l10n.repeatMonthDayListPair, + start: l10n.repeatMonthDayListStart, + ); + final monthDaysSummary = switch (monthDayValues.length) { + 1 => l10n.repeatOnMonthDaysSummary(monthDayValues.single), + 2 => l10n.repeatOnTwoMonthDaysSummary( + monthDayValues.first, + monthDayValues.last, + ), + _ => l10n.repeatOnMonthDaysSummaryMultiple(monthDays), + }; + summary = '$summary${l10n.repeatSummarySeparator}$monthDaysSummary'; + } + } else if (recurrence.bySetPosition case final position?) { + final ordinalPosition = _ordinalPositionKey(position); + final days = _localizedOrdinalDaySummary( + context, + recurrence.byDay, + ordinalPosition, + ); + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty) { + final month = _localizedYearlyMonth(context, recurrence.byMonth.single); + summary = l10n.repeatYearlyOnOrdinalSummary( + summary, + month, + ordinalPosition, + days, + ); + yearlyMonthPlacedInSummary = true; + } else { + final ordinalSummary = l10n.repeatOnOrdinalSummary(ordinalPosition, days); + summary = '$summary${l10n.repeatSummarySeparator}$ordinalSummary'; + } + } + if (recurrence.frequency == RecurrenceFrequency.yearly && + recurrence.byMonth.isNotEmpty && + !yearlyMonthPlacedInSummary) { + final monthValues = recurrence.byMonth + .map((month) => _localizedYearlyMonth(context, month)) + .toList(growable: false); + final months = _localizedRecurrenceList( + monthValues, + pair: l10n.repeatYearlyMonthListPair, + start: l10n.repeatYearlyMonthListStart, + ); + summary = '$summary ${l10n.repeatInMonthsSummary(months)}'; + } + if (recurrence.count != null) { + return '$summary · ${l10n.repeatTimesSummary(recurrence.count!)}'; + } + final until = recurrence.untilDateFor(timeZone: timeZone); + if (until == null) return summary; + final date = DateTime.tryParse(until); + final label = date == null + ? until + : (formatDate ?? + DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()) + .format)(date); + return '$summary · ${l10n.repeatUntilSummary(label)}'; +} + +typedef _OrdinalDayChoice = ({String key, List days}); + +const _ordinalDayChoices = <_OrdinalDayChoice>[ + (key: 'MO', days: ['MO']), + (key: 'TU', days: ['TU']), + (key: 'WE', days: ['WE']), + (key: 'TH', days: ['TH']), + (key: 'FR', days: ['FR']), + (key: 'SA', days: ['SA']), + (key: 'SU', days: ['SU']), + (key: 'day', days: ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']), + (key: 'weekday', days: ['MO', 'TU', 'WE', 'TH', 'FR']), + (key: 'weekend', days: ['SA', 'SU']), +]; + +String _ordinalDayChoiceKey(List days) { + for (final choice in _ordinalDayChoices) { + if (_sameDaySet(choice.days, days)) return choice.key; + } + return days.firstOrNull ?? 'MO'; +} + +DateTime? _weekdayDate(String day) { + final index = rfcWeekdays.indexOf(day); + if (index < 0) return null; + return DateTime(2024, 1, 1 + index); +} + +String _localizedInlineWeekday(BuildContext context, String day) { + final date = _weekdayDate(day); + if (date == null) return day; + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedInlineWeekday(locale, date); +} + +String _localizedDayNumber(BuildContext context, int day) { + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedNumber(locale, day); +} + +String _localizedRecurrenceList( + List values, { + required String Function(String first, String second) pair, + required String Function(String first, String rest) start, +}) { + assert(values.isNotEmpty); + if (values.length == 1) return values.single; + + var result = pair(values[values.length - 2], values.last); + for (var index = values.length - 3; index >= 0; index -= 1) { + result = start(values[index], result); + } + return result; +} + +String _localizedInlineMonth(BuildContext context, int month) { + if (month < 1 || month > 12) return '$month'; + final locale = Localizations.localeOf(context).toLanguageTag(); + return localizedInlineMonth(locale, DateTime(2024, month), abbreviated: true); +} + +String _localizedYearlyMonth(BuildContext context, int month) { + final localizedMonth = _localizedInlineMonth(context, month); + return context.l10n.repeatYearlyMonthValue( + localizedMonth, + _recurrenceMonthKey(month), + ); +} + +String _recurrenceMonthKey(int month) => switch (month) { + 1 => 'jan', + 2 => 'feb', + 3 => 'mar', + 4 => 'apr', + 5 => 'may', + 6 => 'jun', + 7 => 'jul', + 8 => 'aug', + 9 => 'sep', + 10 => 'oct', + 11 => 'nov', + 12 => 'dec', + _ => 'other', +}; + +String _ordinalPositionKey(int value) => switch (value) { + 1 => 'first', + 2 => 'second', + 3 => 'third', + 4 => 'fourth', + 5 => 'fifth', + -2 => 'secondToLast', + -1 => 'last', + _ => '$value', +}; + +String _localizedOrdinalDaySummary( + BuildContext context, + List days, + String ordinalPosition, +) { + final dayKey = _ordinalDayChoiceKey(days); + final ordinalDayKey = Localizations.localeOf(context).languageCode == 'pl' + ? '$ordinalPosition$dayKey' + : dayKey; + if (days.length == 1) { + return context.l10n.repeatOrdinalDaySummary( + ordinalDayKey, + _localizedInlineWeekday(context, days.single), + ); + } + if (dayKey == 'day' || dayKey == 'weekday' || dayKey == 'weekend') { + return context.l10n.repeatOrdinalDaySummary(ordinalDayKey, ''); + } + final dayValues = days + .map((day) => _localizedInlineWeekday(context, day)) + .toList(growable: false); + final localizedDays = _localizedRecurrenceList( + dayValues, + pair: context.l10n.repeatWeekdayListPair, + start: context.l10n.repeatWeekdayListStart, + ); + return context.l10n.repeatOrdinalDaySummary(ordinalDayKey, localizedDays); +} + +String _localizedWeeklyDaySummary(BuildContext context, String day) { + return context.l10n.repeatWeeklyDaySummary( + day, + _localizedInlineWeekday(context, day), + ); +} + +bool _sameDaySet(List left, List right) { + if (left.length != right.length) return false; + final leftValues = {...left}; + return leftValues.length == left.length && leftValues.containsAll(right); +} diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index a38338ca..fc771716 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -209,7 +209,10 @@ class _ScheduleAgendaViewState extends State { ) { final dated = items.where((item) => item.start != null).toList(); final noDateTasks = ScheduleProjection.noDateTasks(items); - final groups = ScheduleProjection.groupByDay(dated); + final groups = ScheduleProjection.groupByDay( + dated, + visibleRange: widget.range, + ); final rangeStart = ScheduleProjection.day(widget.range.start); final rangeEnd = ScheduleProjection.day(widget.range.end); final overdueTasks = dated.whereType().where((item) { diff --git a/lib/src/features/schedule/presentation/schedule_month_view.dart b/lib/src/features/schedule/presentation/schedule_month_view.dart index 98ee8b6e..93adacd8 100644 --- a/lib/src/features/schedule/presentation/schedule_month_view.dart +++ b/lib/src/features/schedule/presentation/schedule_month_view.dart @@ -52,7 +52,11 @@ class ScheduleMonthView extends StatelessWidget { final days = _daysInRange(range); final rows = (days.length / DateTime.daysPerWeek).ceil(); final month = DateTime(selectedDate.year, selectedDate.month); - final grouped = ScheduleProjection.groupByDay(items); + final grouped = ScheduleProjection.groupByDay( + items, + visibleRange: range, + repeatCalendarEvents: true, + ); final theme = Theme.of(context); final workspaceColor = BusyMaxSurfaceColors.of(context).window; final border = busyMaxCalendarGridColor(context); diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 9a64aaea..e66ed9d4 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -55,6 +55,7 @@ import '../../tasks/domain/task_checklist_item.dart'; import '../../tasks/presentation/new_task_dialog.dart'; import '../../tasks/presentation/task_details_pane.dart'; import '../../maps/application/external_location_launcher.dart'; +import '../../recurrence/presentation/recurrence_summary.dart'; import '../application/saved_schedule_location.dart'; import 'schedule_agenda_view.dart'; import 'schedule_anchored_popover.dart'; @@ -2187,6 +2188,27 @@ class _ScheduleWorkspaceState extends ConsumerState { try { final result = await ScheduleReschedulingCoordinator( repository: ref.read(calendarRepositoryProvider), + confirmSeriesTimingChange: (change, scope) async { + if (!mounted) return false; + final following = scope == RecurringEventMutationScope.thisAndFuture; + return showBusyMaxConfirm( + context, + title: context.l10n.repeat, + message: context.l10n.seriesPatternChangeConfirmation( + recurrenceRuleSummary( + context, + change.previewRule(following: following, original: true), + timeZone: change.timeZone, + ), + recurrenceRuleSummary( + context, + change.previewRule(following: following), + timeZone: change.timeZone, + ), + ), + confirmLabel: context.l10n.save, + ); + }, chooseScope: (detail, following) => _chooseRecurringEventMutationScope( detail.provider, editing: true, diff --git a/lib/src/features/settings/presentation/account_removal_dialog.dart b/lib/src/features/settings/presentation/account_removal_dialog.dart index 456d74ea..15522364 100644 --- a/lib/src/features/settings/presentation/account_removal_dialog.dart +++ b/lib/src/features/settings/presentation/account_removal_dialog.dart @@ -16,6 +16,7 @@ Future showBusyMaxAccountRemovalDialog( BuildContext context, { required String accountLabel, required bool canRevokeGoogleAuthorization, + bool revokeNextcloudAppPassword = false, }) { return showBusyMaxModalDialog( context, @@ -23,6 +24,7 @@ Future showBusyMaxAccountRemovalDialog( builder: (dialogContext) => _AccountRemovalDialog( accountLabel: accountLabel, canRevokeGoogleAuthorization: canRevokeGoogleAuthorization, + revokeNextcloudAppPassword: revokeNextcloudAppPassword, ), ); } @@ -31,10 +33,12 @@ class _AccountRemovalDialog extends StatefulWidget { const _AccountRemovalDialog({ required this.accountLabel, required this.canRevokeGoogleAuthorization, + required this.revokeNextcloudAppPassword, }); final String accountLabel; final bool canRevokeGoogleAuthorization; + final bool revokeNextcloudAppPassword; @override State<_AccountRemovalDialog> createState() => _AccountRemovalDialogState(); @@ -67,7 +71,10 @@ class _AccountRemovalDialogState extends State<_AccountRemovalDialog> { ), ], children: [ + Text(widget.accountLabel), Text(l10n.removeAccountConfirmation), + if (widget.revokeNextcloudAppPassword) + Text(l10n.nextcloudRemovalAuthorizationDescription), if (widget.canRevokeGoogleAuthorization) ...[ const SizedBox(height: BusyMaxSpacing.lg), BusyMaxYaruFocusBorder( diff --git a/lib/src/features/settings/presentation/account_settings_feedback.dart b/lib/src/features/settings/presentation/account_settings_feedback.dart new file mode 100644 index 00000000..ea5bf776 --- /dev/null +++ b/lib/src/features/settings/presentation/account_settings_feedback.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../../../../l10n/generated/app_localizations.dart'; +import '../../../core/auth/oauth_models.dart'; +import '../../../core/http/request_dispatch_exception.dart'; +import '../../../core/secrets/secret_store.dart'; +import '../../../dav/dav_errors.dart'; +import '../../../dav/sync/dav_account_sync_engine.dart'; +import '../../../features/connectivity/network_connectivity_service.dart'; +import '../../../features/sync/sync_auth_error.dart'; +import '../../../features/sync/sync_failure_notification_policy.dart'; +import '../../../features/tasks/domain/task_remote_error.dart'; +import '../../../google_calendar/google_calendar_errors.dart'; +import '../../../microsoft_calendar/microsoft_calendar_errors.dart'; +import '../../../microsoft_todo/api/microsoft_todo_api_error.dart'; +import '../../../l10n/oauth_error_description.dart'; + +enum AccountSettingsRecovery { + retry, + reconnect, + replaceRegistration, + revalidate, +} + +enum AccountSettingsFailure { + network, + authorization, + registration, + permissions, + configuration, + storage, + temporary, +} + +/// Presentation of typed outcomes and persisted DAV codes. Provider messages, +/// exception strings and correlation identifiers never become preference text. +class AccountSettingsFeedback { + const AccountSettingsFeedback(this.kind, this.message, this.recovery); + final AccountSettingsFailure kind; + final String message; + final AccountSettingsRecovery recovery; + + static AccountSettingsFeedback fromError( + AppLocalizations l10n, + Object error, + ) { + error = resolveEffectiveSyncFailure(error); + if (error is DavAccountSyncException) { + if (error.failures.isNotEmpty) { + return fromError(l10n, error.failures.first); + } + } + if (error is SecretStoreException || + error is DavException && + error.code == 'DavCredentialStoreUnavailable') { + return AccountSettingsFeedback( + AccountSettingsFailure.storage, + l10n.oauthSecureStorageUnavailable, + AccountSettingsRecovery.retry, + ); + } + if (error is OAuthException) { + switch (error.classification) { + case OAuthFailureKind.registrationUnavailable: + case OAuthFailureKind.clientRejected: + case OAuthFailureKind.configuration: + return AccountSettingsFeedback( + AccountSettingsFailure.registration, + localizedOAuthFailure( + l10n, + error.classification, + l10n.accountSyncProblem, + ), + AccountSettingsRecovery.replaceRegistration, + ); + case OAuthFailureKind.network: + case OAuthFailureKind.timeout: + return _network(l10n); + case OAuthFailureKind.permission: + return AccountSettingsFeedback( + AccountSettingsFailure.permissions, + l10n.oauthPermissionRefused, + AccountSettingsRecovery.reconnect, + ); + case OAuthFailureKind.storage: + case OAuthFailureKind.corruptRecord: + return AccountSettingsFeedback( + AccountSettingsFailure.storage, + l10n.oauthSecureStorageUnavailable, + AccountSettingsRecovery.retry, + ); + default: + if (isOAuthReconnectRequiredError(error)) { + return AccountSettingsFeedback( + AccountSettingsFailure.authorization, + l10n.accountReconnectRequired, + AccountSettingsRecovery.reconnect, + ); + } + return AccountSettingsFeedback( + AccountSettingsFailure.temporary, + localizedOAuthFailure( + l10n, + error.classification, + l10n.accountSyncProblem, + ), + AccountSettingsRecovery.retry, + ); + } + } + if (error is DavException) { + switch (error.category) { + case DavErrorCategory.davTransientNetwork: + case DavErrorCategory.davTlsFailure: + return _network(l10n); + case DavErrorCategory.davAuthRejected: + case DavErrorCategory.davCredentialsRevoked: + return AccountSettingsFeedback( + AccountSettingsFailure.authorization, + l10n.accountReconnectRequired, + AccountSettingsRecovery.reconnect, + ); + case DavErrorCategory.davPermissionDenied: + case DavErrorCategory.davReadOnly: + return AccountSettingsFeedback( + AccountSettingsFailure.permissions, + l10n.davPermissionChanged, + AccountSettingsRecovery.revalidate, + ); + case DavErrorCategory.davUnsupportedServer: + case DavErrorCategory.davUnsupportedComponent: + case DavErrorCategory.davProtocolViolation: + return AccountSettingsFeedback( + AccountSettingsFailure.configuration, + l10n.davUnsupportedServer, + AccountSettingsRecovery.revalidate, + ); + default: + break; + } + } + if (error is NetworkUnavailableException || + error is SocketException || + error is TimeoutException || + error is http.ClientException) { + return _network(l10n); + } + final status = switch (error) { + GoogleCalendarApiError(:final statusCode) => statusCode, + MicrosoftCalendarApiError(:final statusCode) => statusCode, + MicrosoftTodoApiError(:final statusCode) => statusCode, + TaskRemoteError(:final statusCode) => statusCode, + _ => null, + }; + final throttled = + status == 429 || error is GoogleCalendarApiError && error.isRateLimited; + if (throttled || + status != null && (status >= 500 || status == 408 || status == 425)) { + return AccountSettingsFeedback( + AccountSettingsFailure.temporary, + throttled + ? l10n.oauthProviderThrottled + : l10n.oauthProviderTemporaryFailure, + AccountSettingsRecovery.retry, + ); + } + final disposition = syncFailureNotificationDisposition(error); + if (disposition == SyncFailureNotificationDisposition.reconnectRequired) { + return AccountSettingsFeedback( + AccountSettingsFailure.authorization, + l10n.accountReconnectRequired, + AccountSettingsRecovery.reconnect, + ); + } + if (disposition == SyncFailureNotificationDisposition.permissionChanged) { + return AccountSettingsFeedback( + AccountSettingsFailure.permissions, + l10n.davPermissionChanged, + AccountSettingsRecovery.reconnect, + ); + } + return AccountSettingsFeedback( + AccountSettingsFailure.temporary, + l10n.accountSyncProblem, + AccountSettingsRecovery.retry, + ); + } + + static AccountSettingsFeedback fromDavCode( + AppLocalizations l10n, + String code, + ) { + // These are persisted app-owned codes, not free-form server descriptions. + const kinds = { + 'DavNetworkFailure': DavErrorKind.network, + 'DavRequestTimeout': DavErrorKind.timeout, + 'DavTimeout': DavErrorKind.timeout, + 'DavTlsFailure': DavErrorKind.tls, + 'DavAuthRejected': DavErrorKind.authentication, + 'DavCredentialsRevoked': DavErrorKind.authentication, + 'DavPermissionDenied': DavErrorKind.authorization, + 'DavReadOnly': DavErrorKind.authorization, + 'DavUnsupportedServer': DavErrorKind.protocol, + 'DavUnsupportedProfile': DavErrorKind.protocol, + 'DavUnsupportedComponent': DavErrorKind.unsupportedComponent, + 'DavServerUnavailable': DavErrorKind.server, + 'DavCredentialStoreUnavailable': DavErrorKind.network, + }; + final kind = kinds[code]; + return kind == null + ? AccountSettingsFeedback( + AccountSettingsFailure.temporary, + l10n.accountSyncProblem, + AccountSettingsRecovery.retry, + ) + : fromError( + l10n, + DavException(kind: kind, code: code, safeMessage: ''), + ); + } + + static AccountSettingsFeedback _network(AppLocalizations l10n) => + AccountSettingsFeedback( + AccountSettingsFailure.network, + l10n.accountServiceUnreachable, + AccountSettingsRecovery.retry, + ); +} diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 9c1a22a7..3dfbc6b7 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -1,4 +1,5 @@ import '../../../l10n/oauth_error_description.dart'; +import '../../../l10n/contacts_error_description.dart'; import 'package:busymax/src/core/auth/authorization_attempt.dart'; @@ -10,6 +11,11 @@ import 'package:busymax/src/l10n/time_format_scope.dart'; import 'package:busymax/src/l10n/week_preferences_scope.dart'; import 'dart:async'; +import 'dart:convert'; + +import '../../../calendar_providers/calendar_mutation.dart'; +import '../../../db/app_database.dart' show PendingOp; +import '../../../microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -61,6 +67,7 @@ import '../../connectivity/network_connectivity_service.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; import '../../feedback/presentation/feedback_dialog.dart'; import '../../sync/sync_auth_error.dart'; +import 'account_settings_feedback.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../../tasks/presentation/desktop_date_time_fields.dart'; import '../../tasks/domain/task_capabilities.dart'; @@ -88,11 +95,21 @@ class SettingsScreen extends ConsumerStatefulWidget { class _SettingsScreenState extends ConsumerState { late var _page = widget.initialPage; + ({String id, bool contactsOnly})? _accountDetails; + final _contentScroll = ScrollController(); + final _headerBackFocus = FocusNode(debugLabel: 'settings-back'); + final _accountEntryFocus = {}; + double _accountsOverviewOffset = 0; + String? _originatingAccountEntry; + int _accountNavigationGeneration = 0; BusyProvider? _connectingProvider; AuthorizationCancellation? _authorizationCancellation; AuthorizationCancellation? _sharedConsentCancellation; DavCancellationToken? _davCancellation; final _removingAccountIds = {}; + final _registrationRecoveryIds = {}; + final _refreshingAccountIds = {}; + BusyMaxContactsController? _contactOperationsController; final _busySubscriptionIds = {}; final _busyContactAccountIds = {}; late final LaunchAtLoginRefreshObserver _autostartRefresh; @@ -102,8 +119,13 @@ class _SettingsScreenState extends ConsumerState { final id = widget.reconnectAccountId; if (id == null || id == _handledRecovery) return; _handledRecovery = id; + final navigationGeneration = _accountNavigationGeneration; final account = await ref.read(accountsRepositoryProvider).accountById(id); - if (mounted && account != null) { + if (!mounted || navigationGeneration != _accountNavigationGeneration) { + return; + } + if (account != null) { + _openAccountDetails(account.id); await _connectAccount(account.provider, reconnecting: account); } if (!mounted) return; @@ -133,6 +155,14 @@ class _SettingsScreenState extends ConsumerState { _sharedConsentCancellation?.cancel(); _autostartRefresh.dispose(); _davCancellation?.cancel(); + for (final id in _busyContactAccountIds) { + _contactOperationsController?.cancelEnrollment(id); + } + _contentScroll.dispose(); + _headerBackFocus.dispose(); + for (final node in _accountEntryFocus.values) { + node.dispose(); + } super.dispose(); } @@ -145,7 +175,10 @@ class _SettingsScreenState extends ConsumerState { (_) => _openRequestedRecovery(), ); } - if (oldWidget.initialPage != widget.initialPage) { + if (oldWidget.initialPage != widget.initialPage && + _page != widget.initialPage) { + _abandonAccountAuthorization(); + _accountDetails = null; _page = widget.initialPage; } } @@ -183,7 +216,39 @@ class _SettingsScreenState extends ConsumerState { final settingsController = ref.read(appSettingsControllerProvider.notifier); final themeController = ref.read(busyMaxThemeControllerProvider); final l10n = context.l10n; - final title = _settingsPageLabel(context, _page); + final detailAccount = _accountDetails?.contactsOnly == false + ? accounts.where((a) => a.id == _accountDetails!.id).firstOrNull + : null; + final detailContact = _accountDetails?.contactsOnly == true + ? contactAccounts.where((a) => a.id == _accountDetails!.id).firstOrNull + : null; + final title = _page == SettingsPage.accounts && _accountDetails != null + ? detailAccount?.displayLabel ?? + detailContact?.displayName ?? + l10n.accounts + : _settingsPageLabel(context, _page); + ref.listen(accountManagementStreamProvider, (_, next) { + final target = _accountDetails; + if (target != null && + !target.contactsOnly && + next.hasValue && + !next.isLoading && + !next.hasError && + !next.requireValue.any((a) => a.id == target.id)) { + _returnAfterAccountDisappeared(target.id); + } + }); + ref.listen(busyMaxContactAccountsProvider, (_, next) { + final target = _accountDetails; + if (target != null && + target.contactsOnly && + next.hasValue && + !next.isLoading && + !next.hasError && + !next.requireValue.any((a) => a.id == target.id)) { + _returnAfterAccountDisappeared(target.id); + } + }); final hasSyncEligibleAccounts = allAccounts.any( (account) => account.isSyncEligible, ); @@ -211,6 +276,19 @@ class _SettingsScreenState extends ConsumerState { final pageBody = switch (_page) { SettingsPage.accounts => _AccountManagementSection( + selectedAccountId: _accountDetails?.contactsOnly == false + ? _accountDetails?.id + : null, + selectedContactAccountId: _accountDetails?.contactsOnly == true + ? _accountDetails?.id + : null, + onOpenAccount: _openAccountDetails, + onOpenContactAccount: (id) => + _openAccountDetails(id, contactsOnly: true), + entryFocusNode: (id) => _accountEntryFocus.putIfAbsent( + id, + () => FocusNode(debugLabel: 'account-entry-$id'), + ), accounts: _selectedAccountFirst(accounts, selectedAccount?.id), googleConfigured: config.googleSetupAvailable, microsoftConfigured: config.microsoftSetupAvailable, @@ -236,6 +314,11 @@ class _SettingsScreenState extends ConsumerState { onReconnect: (account) => unawaited(_connectAccount(account.provider, reconnecting: account)), removingAccountIds: _removingAccountIds, + registrationRecoveryIds: _registrationRecoveryIds, + refreshingAccountIds: _refreshingAccountIds, + onRetryContacts: (contact) => unawaited(_retryContacts(contact)), + onReconnectContacts: (contact) => + unawaited(_reconnectContacts(contact)), onRemoveAccount: (account) => unawaited(_removeAccount(context, ref, account)), contactAccounts: contactAccounts, @@ -612,6 +695,7 @@ class _SettingsScreenState extends ConsumerState { ), Expanded( child: BusyMaxClamp( + controller: _contentScroll, maxWidth: 760, margin: EdgeInsets.zero, padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), @@ -626,6 +710,7 @@ class _SettingsScreenState extends ConsumerState { return LinuxPageFrame( header: _SettingsHeader( title: title, + backFocusNode: _headerBackFocus, onBack: _goBack, onMenuSelected: _handleMenuAction, ), @@ -655,17 +740,23 @@ class _SettingsScreenState extends ConsumerState { } }); - Future _disableContacts(String id) => _runContactOperation( - id, - () async { - final controller = ref.read(busyMaxContactsControllerProvider); - if (id.startsWith('contacts:')) { - await controller.removeLinkedAccount(id.substring('contacts:'.length)); - } else { - await controller.removeContactsAccount(id); - } - }, - ); + Future _disableContacts(String id) => + _runContactOperation(id, () async { + final controller = ref.read(busyMaxContactsControllerProvider); + if (id.startsWith('contacts:')) { + await controller.disableContactsAccount(id); + } else { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.removeAccount, + message: context.l10n.contactsOnlyDescription, + confirmLabel: context.l10n.removeAction, + destructive: true, + ); + if (!confirmed || !mounted) return; + await controller.removeContactsAccount(id); + } + }); Future _addCardDavContacts() async { final input = await showBusyMaxModalDialog<_CardDavContactsInput>( @@ -703,11 +794,64 @@ class _SettingsScreenState extends ConsumerState { }); } + Future _retryContacts(ContactAccount contact) => _runContactOperation( + contact.id, + () => ref + .read(busyMaxContactsControllerProvider) + .synchronizeAccount(contact.id), + ); + + Future _reconnectContacts(ContactAccount contact) async { + // Preparation can outlive its account view before any enrollment exists + // for navigation to cancel. Own the intent from the first storage read. + final navigationGeneration = _accountNavigationGeneration; + bool ownsIntent() => + mounted && navigationGeneration == _accountNavigationGeneration; + final controller = ref.read(busyMaxContactsControllerProvider); + final parentId = await controller.store.linkedBusyMaxAccount(contact.id); + if (!ownsIntent()) return; + if (parentId != null && contact.provider == ContactProviderKind.carddav) { + final parent = await ref + .read(accountsRepositoryProvider) + .accountById(parentId); + if (!ownsIntent() || parent == null) return; + if (await _connectAccount(parent.provider, reconnecting: parent) && + ownsIntent()) { + await _enableContacts(parent); + } + return; + } + String? password; + if (parentId == null) { + final nextcloud = await controller.isIndependentNextcloud(contact.id); + if (!mounted || !ownsIntent()) return; + if (!nextcloud) { + password = await showBusyMaxTextPrompt( + context, + title: context.l10n.reconnectAccount, + label: context.l10n.contactsPasswordLabel, + actionLabel: context.l10n.reconnectAccount, + obscureText: true, + ); + if (!ownsIntent() || password == null) return; + } + } + if (!ownsIntent()) return; + await _runContactOperation( + contact.id, + () => password != null + ? controller.reconnectDavContactsOnly(contact.id, password: password) + : controller.reconnectContactsAccount(contact.id), + ); + } + Future _runContactOperation( String id, Future Function() operation, ) async { if (_busyContactAccountIds.contains(id)) return; + final navigationGeneration = _accountNavigationGeneration; + _contactOperationsController = ref.read(busyMaxContactsControllerProvider); setState(() => _busyContactAccountIds.add(id)); try { await operation(); @@ -715,8 +859,23 @@ class _SettingsScreenState extends ConsumerState { _settingsLogger.warning( 'Contacts configuration failed: ${redactForLog(error)}', ); - if (mounted) { - _showMessage(context, context.l10n.operationFailed); + if (mounted && navigationGeneration == _accountNavigationGeneration) { + final target = _accountDetails; + if (target?.contactsOnly == false && + error is OAuthException && + const [ + OAuthFailureKind.registrationUnavailable, + OAuthFailureKind.clientRejected, + OAuthFailureKind.configuration, + ].contains(error.classification) && + (id == target!.id || + id == + BusyMaxContactsController.linkedContactAccountId( + target.id, + ))) { + setState(() => _registrationRecoveryIds.add(target.id)); + } + _showMessage(context, localizedContactsError(context.l10n, error)); } } finally { if (mounted) setState(() => _busyContactAccountIds.remove(id)); @@ -854,6 +1013,11 @@ class _SettingsScreenState extends ConsumerState { } void _goBack() { + if (_page == SettingsPage.accounts && _accountDetails != null) { + _showAccountsOverview(); + return; + } + _abandonAccountAuthorization(); if (context.canPop()) { context.pop(); return; @@ -863,10 +1027,15 @@ class _SettingsScreenState extends ConsumerState { void _selectPage(SettingsPage page) { if (_page != page) { + _abandonAccountAuthorization(); + _accountDetails = null; if (page == SettingsPage.system) { ref.invalidate(launchAtLoginStateProvider); } setState(() => _page = page); + _restoreAccountScroll( + page == SettingsPage.accounts ? _accountsOverviewOffset : 0, + ); } final router = GoRouter.maybeOf(context); if (router == null) { @@ -894,16 +1063,94 @@ class _SettingsScreenState extends ConsumerState { ); } - Future _connectAccount( + void _abandonAccountAuthorization() { + _accountNavigationGeneration++; + _cancelAccountConnection(); + _sharedConsentCancellation?.cancel(); + for (final id in _busyContactAccountIds) { + _contactOperationsController?.cancelEnrollment(id); + } + } + + void _openAccountDetails(String id, {bool contactsOnly = false}) { + if (_page == SettingsPage.accounts && + _accountDetails == null && + _contentScroll.hasClients) { + _accountsOverviewOffset = _contentScroll.offset; + } + _abandonAccountAuthorization(); + _originatingAccountEntry = '${contactsOnly ? 'contacts' : 'account'}:$id'; + setState(() { + _page = SettingsPage.accounts; + _accountDetails = (id: id, contactsOnly: contactsOnly); + }); + _restoreAccountScroll(0, focus: _headerBackFocus); + } + + void _showAccountsOverview() { + _abandonAccountAuthorization(); + setState(() => _accountDetails = null); + _restoreAccountScroll( + _accountsOverviewOffset, + focus: _accountEntryFocus[_originatingAccountEntry], + ); + } + + void _returnAfterAccountDisappeared(String id) { + final generation = _accountNavigationGeneration; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + generation != _accountNavigationGeneration || + _accountDetails?.id != id) { + return; + } + final remaining = + (ref.read(accountManagementStreamProvider).valueOrNull ?? + const []) + .where((a) => a.id != id) + .toList(); + final contacts = + ref.read(busyMaxContactAccountsProvider).valueOrNull ?? + const []; + final independent = + ref.read(independentContactAccountIdsProvider).valueOrNull ?? + const {}; + final contact = contacts + .where((c) => c.id != id && independent.contains(c.id)) + .firstOrNull; + _originatingAccountEntry = remaining.isNotEmpty + ? 'account:${remaining.first.id}' + : contact != null + ? 'contacts:${contact.id}' + : 'add-account'; + _showAccountsOverview(); + }); + } + + void _restoreAccountScroll(double offset, {FocusNode? focus}) { + final generation = _accountNavigationGeneration; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || generation != _accountNavigationGeneration) return; + if (_contentScroll.hasClients) { + _contentScroll.jumpTo( + offset.clamp(0, _contentScroll.position.maxScrollExtent).toDouble(), + ); + } + if (focus?.context != null) focus!.requestFocus(); + }); + } + + Future _connectAccount( BusyProvider provider, { AccountEntity? reconnecting, bool replaceRegistration = false, }) async { if (_connectingProvider != null || _authorizationCancellation?.isCancelled == false) { - return; + return false; } var addSeparate = false; + var connected = false; final ownedCancellation = AuthorizationCancellation(); _authorizationCancellation = ownedCancellation; try { @@ -914,29 +1161,24 @@ class _SettingsScreenState extends ConsumerState { context, fixedEmail: reconnecting?.email ?? reconnecting?.providerAccountId, ); - if (appleInput == null || !mounted) return; + if (appleInput == null || !mounted || ownedCancellation.isCancelled) { + return false; + } } else if (provider == BusyProvider.nextcloud) { nextcloudServer = await showNextcloudServerDialog( context, initialServer: reconnecting?.authority, ); - if (nextcloudServer == null || !mounted) return; + if (nextcloudServer == null || + !mounted || + ownedCancellation.isCancelled) { + return false; + } } AuthorizationRequest? request; if (provider == BusyProvider.google || provider == BusyProvider.microsoft) { - final summary = reconnecting == null - ? null - : ref - .read(registrationSummariesProvider) - .valueOrNull?[reconnecting.id]; - final googleRecovery = - provider == BusyProvider.google && - reconnecting != null && - (reconnecting.needsReconnect || - summary == null || - summary.origin == RegistrationOrigin.retiringShared); - if (reconnecting != null && !replaceRegistration && !googleRecovery) { + if (reconnecting != null && !replaceRegistration) { request = AuthorizationRequest.reconnect(reconnecting.id); } else { final handle = await showRegistrationSetup( @@ -949,19 +1191,24 @@ class _SettingsScreenState extends ConsumerState { reconnecting.displayName ?? reconnecting.id, ); - if (handle == null || !mounted) return; + if (handle == null || !mounted) return false; request = reconnecting == null ? AuthorizationRequest.newConnection(handle) : AuthorizationRequest.replace(reconnecting.id, handle); } } + if (!mounted || + !identical(_authorizationCancellation, ownedCancellation) || + ownedCancellation.isCancelled) { + return false; + } final repository = ref.read(authRepositoryProvider); final runSync = ref.read(signedInSyncRunnerProvider); final sessionController = ref.read( authSessionControllerProvider.notifier, ); setState(() => _connectingProvider = provider); - if (!mounted || ownedCancellation.isCancelled) return; + if (!mounted || ownedCancellation.isCancelled) return false; request = (request ?? const AuthorizationRequest.newConnection(null)) .withCancellation(ownedCancellation); try { @@ -1007,16 +1254,24 @@ class _SettingsScreenState extends ConsumerState { ); } if (accountId != null) { + ownedCancellation.committed(); + connected = true; + if (mounted) { + setState(() => _registrationRecoveryIds.remove(accountId)); + } unawaited(_syncConnectedAccount(runSync, accountId)); await sessionController.reconcileConnectedAccount(accountId); } } on Object catch (error) { if ((error is OAuthException && error.code == 'OAuthSignInCancelled') || (error is DavException && error.kind == DavErrorKind.cancelled)) { - return; + return false; } if (mounted && + identical(_authorizationCancellation, ownedCancellation) && + !ownedCancellation.isCancelled && error is OAuthException && + provider == BusyProvider.google && error.classification == OAuthFailureKind.identityUnverified) { addSeparate = await showBusyMaxModalDialog( @@ -1047,7 +1302,17 @@ class _SettingsScreenState extends ConsumerState { ), ) ?? false; - } else if (mounted) { + } else if (mounted && + identical(_authorizationCancellation, ownedCancellation) && + !ownedCancellation.isCancelled) { + if (reconnecting != null && + error is OAuthException && + (error.classification == + OAuthFailureKind.registrationUnavailable || + error.classification == OAuthFailureKind.clientRejected || + error.classification == OAuthFailureKind.configuration)) { + setState(() => _registrationRecoveryIds.add(reconnecting.id)); + } _showMessage(context, _accountConnectionErrorMessage(context, error)); } } finally { @@ -1066,17 +1331,15 @@ class _SettingsScreenState extends ConsumerState { if (mounted) setState(() => _connectingProvider = null); } } - if (addSeparate && mounted) await _connectAccount(BusyProvider.google); + if (addSeparate && mounted) return _connectAccount(BusyProvider.google); + return connected; } void _cancelAccountConnection() { - final provider = _connectingProvider; + if (_authorizationCancellation?.wasCommitted == true) return; _authorizationCancellation?.cancel(); setState(() => _connectingProvider = null); _davCancellation?.cancel(); - if (provider == BusyProvider.nextcloud) { - ref.read(davAccountOnboardingServiceProvider).cancelNextcloudLogin(); - } } Future _syncConnectedAccount( @@ -1086,15 +1349,13 @@ class _SettingsScreenState extends ConsumerState { try { await runSync(accountId, true); } on Object catch (error) { - if (mounted) { + if (mounted && + _page == SettingsPage.accounts && + (_accountDetails == null || _accountDetails?.id == accountId)) { _showMessage( context, context.l10n.syncFailed( - localizedSyncFailureMessage( - context.l10n, - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), + AccountSettingsFeedback.fromError(context.l10n, error).message, ), ); } @@ -1112,7 +1373,8 @@ class _SettingsScreenState extends ConsumerState { final options = await showBusyMaxAccountRemovalDialog( context, - accountLabel: account.displayLabel, + accountLabel: _accountIdentityLabel(context, account), + revokeNextcloudAppPassword: account.provider == BusyProvider.nextcloud, canRevokeGoogleAuthorization: account.provider == BusyProvider.google && account.isSignedIn, ); @@ -1139,9 +1401,13 @@ class _SettingsScreenState extends ConsumerState { .removeAccount(account.id) : null; if (context.mounted) { - if (result?.authorizationRevocationFailed ?? false) { + final presenting = + _page == SettingsPage.accounts && + (_accountDetails == null || _accountDetails?.id == account.id); + if (presenting && (result?.authorizationRevocationFailed ?? false)) { _showMessage(context, context.l10n.accountRemovedGoogleRevokeFailed); - } else if (davResult?.remoteRevocationAttempted == true && + } else if (presenting && + davResult?.remoteRevocationAttempted == true && !davResult!.remoteRevocationSucceeded) { _showMessage( context, @@ -1149,10 +1415,15 @@ class _SettingsScreenState extends ConsumerState { ); } await _afterAccountRemoved(context, ref, account.id); + if (mounted && _accountDetails?.id == account.id) { + _returnAfterAccountDisappeared(account.id); + } } } on Object catch (error) { _settingsLogger.warning('Account removal failed: $error'); - if (context.mounted) { + if (context.mounted && + _page == SettingsPage.accounts && + (_accountDetails == null || _accountDetails?.id == account.id)) { _showMessage( context, localizedAccountRemovalError(context.l10n, error), @@ -1190,11 +1461,7 @@ class _SettingsScreenState extends ConsumerState { _showMessage( context, context.l10n.syncFailed( - localizedSyncFailureMessage( - context.l10n, - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), + AccountSettingsFeedback.fromError(context.l10n, error).message, ), ); } @@ -1202,10 +1469,26 @@ class _SettingsScreenState extends ConsumerState { } Future _refreshCollections(AccountEntity account) async { + if (_refreshingAccountIds.contains(account.id)) return; + final navigationGeneration = _accountNavigationGeneration; + setState(() => _refreshingAccountIds.add(account.id)); try { - final current = await ref + var current = await ref .read(accountsRepositoryProvider) .accountById(account.id); + if (current?.provider == BusyProvider.nextcloud && + (current?.connectionState == + AccountConnectionState.permissionChanged || + current?.connectionState == + AccountConnectionState.unsupportedServerProfile)) { + await ref + .read(davAccountOnboardingServiceProvider) + .revalidateAccount(account.id); + if (!mounted) return; + current = await ref + .read(accountsRepositoryProvider) + .accountById(account.id); + } if (current?.isSyncEligible != true) { if (current?.needsReconnect == true && mounted) { _showMessage( @@ -1216,20 +1499,20 @@ class _SettingsScreenState extends ConsumerState { return; } await ref.read(signedInSyncRunnerProvider)(account.id, true); - if (mounted) _showMessage(context, context.l10n.syncComplete); + if (mounted && navigationGeneration == _accountNavigationGeneration) { + _showMessage(context, context.l10n.syncComplete); + } } on Object catch (error) { - if (mounted) { + if (mounted && navigationGeneration == _accountNavigationGeneration) { _showMessage( context, context.l10n.syncFailed( - localizedSyncFailureMessage( - context.l10n, - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), + AccountSettingsFeedback.fromError(context.l10n, error).message, ), ); } + } finally { + if (mounted) setState(() => _refreshingAccountIds.remove(account.id)); } } @@ -1242,6 +1525,7 @@ class _SettingsScreenState extends ConsumerState { context, title: context.l10n.openSharedCalendar, label: context.l10n.calendarOwnerEmail, + message: context.l10n.openSharedPrimaryDescription, actionLabel: context.l10n.openSharedCalendar, ); if (!mounted || owner == null || owner.trim().isEmpty) return; @@ -1260,7 +1544,12 @@ class _SettingsScreenState extends ConsumerState { } } on Object catch (error) { if (mounted) { - _showMessage(context, context.l10n.calendarUpdateFailed('$error')); + _showMessage( + context, + context.l10n.calendarUpdateFailed( + AccountSettingsFeedback.fromError(context.l10n, error).message, + ), + ); } } } finally { @@ -1318,11 +1607,7 @@ class _SettingsScreenState extends ConsumerState { _showMessage( context, context.l10n.calendarUpdateFailed( - localizedSyncFailureMessage( - context.l10n, - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), + AccountSettingsFeedback.fromError(context.l10n, error).message, ), ); } @@ -1438,11 +1723,13 @@ class _SettingsPageSelector extends StatelessWidget { class _SettingsHeader extends StatelessWidget { const _SettingsHeader({ required this.title, + required this.backFocusNode, required this.onBack, required this.onMenuSelected, }); final String title; + final FocusNode backFocusNode; final VoidCallback onBack; final ValueChanged onMenuSelected; @@ -1453,6 +1740,7 @@ class _SettingsHeader extends StatelessWidget { children: [ BusyMaxLinuxHeaderIconButton( key: const ValueKey('settings-header-back-button'), + focusNode: backFocusNode, tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: BusyMaxLinuxHeaderIcon.back, onPressed: onBack, @@ -1591,8 +1879,13 @@ List _scheduleDayEndValues(AppSettings settings) { String _timeOfDayLabel(BuildContext context, int minute) => formatScheduleBoundary(context, minute); -class _AccountManagementSection extends StatelessWidget { +class _AccountManagementSection extends ConsumerWidget { const _AccountManagementSection({ + required this.selectedAccountId, + required this.selectedContactAccountId, + required this.onOpenAccount, + required this.onOpenContactAccount, + required this.entryFocusNode, required this.accounts, required this.googleConfigured, required this.microsoftConfigured, @@ -1607,6 +1900,10 @@ class _AccountManagementSection extends StatelessWidget { required this.onReconnect, required this.onMigrate, required this.removingAccountIds, + required this.registrationRecoveryIds, + required this.refreshingAccountIds, + required this.onRetryContacts, + required this.onReconnectContacts, required this.onRemoveAccount, required this.contactAccounts, required this.contactSources, @@ -1637,6 +1934,9 @@ class _AccountManagementSection extends StatelessWidget { }); final List accounts; + final String? selectedAccountId, selectedContactAccountId; + final ValueChanged onOpenAccount, onOpenContactAccount; + final FocusNode Function(String id) entryFocusNode; final bool googleConfigured; final bool microsoftConfigured; final BusyProvider? connectingProvider; @@ -1650,6 +1950,8 @@ class _AccountManagementSection extends StatelessWidget { final void Function(AccountEntity account) onReconnect; final void Function(AccountEntity account) onMigrate; final Set removingAccountIds; + final Set registrationRecoveryIds, refreshingAccountIds; + final void Function(ContactAccount) onRetryContacts, onReconnectContacts; final void Function(AccountEntity account) onRemoveAccount; final List contactAccounts; final List contactSources; @@ -1689,55 +1991,128 @@ class _AccountManagementSection extends StatelessWidget { onChangeSubscriptionColor; final void Function(WebCalSubscriptionEntity subscription) onUnsubscribe; + List _contactConnectionOptions( + BuildContext context, + AccountEntity account, + ) { + final contact = contactAccounts + .where( + (c) => + c.id == + BusyMaxContactsController.linkedContactAccountId(account.id), + ) + .firstOrNull; + if (contact == null) return const []; + final busy = + busyContactAccountIds.contains(account.id) || + busyContactAccountIds.contains(contact.id); + return [ + BusyMaxActionRow( + title: context.l10n.contactsRetrySync, + leading: const Icon(YaruIcons.sync), + onTap: busy || !contact.enabled ? null : () => onRetryContacts(contact), + ), + BusyMaxActionRow( + title: + '${context.l10n.reconnectAccount} · ${context.l10n.contactsTitle}', + leading: const Icon(YaruIcons.refresh), + onTap: busy ? null : () => onReconnectContacts(contact), + ), + ]; + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; final connecting = connectingProvider != null; + final overview = + selectedAccountId == null && selectedContactAccountId == null; return _SettingsPageLayout( children: [ - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxActionRow( - title: connectingProvider == BusyProvider.nextcloud - ? l10n.waitingForNextcloud - : l10n.addNextcloudAccount, - leading: const Icon(YaruIcons.plus), - onTap: connecting ? null : onAddNextcloud, - ), - BusyMaxActionRow( - title: connectingProvider == BusyProvider.google - ? l10n.waitingForGoogleSignIn - : l10n.addGoogleAccount, - subtitle: googleConfigured ? null : l10n.providerNotConfigured, - leading: const Icon(YaruIcons.plus), - onTap: connecting || !googleConfigured ? null : onAddGoogle, - ), - BusyMaxActionRow( - title: connectingProvider == BusyProvider.microsoft - ? l10n.waitingForMicrosoftSignIn - : l10n.addMicrosoftAccount, - subtitle: microsoftConfigured ? null : l10n.providerNotConfigured, - leading: const Icon(YaruIcons.plus), - onTap: connecting || !microsoftConfigured ? null : onAddMicrosoft, - ), - BusyMaxActionRow( - title: connectingProvider == BusyProvider.appleICloud - ? l10n.waitingForAppleICloud - : l10n.addAppleICloudAccount, - leading: const Icon(YaruIcons.plus), - onTap: connecting ? null : onAddApple, - ), - if (connecting) + if (!overview && connecting) + BusyMaxGroupedList( + filled: true, + children: [ BusyMaxActionRow( title: l10n.cancelAccountConnection, leading: const Icon(YaruIcons.window_close), onTap: onCancelConnection, ), - ], - ), - for (final account in accounts) + ], + ), + if (overview) ...[ + BusyMaxGroupedList( + title: l10n.addAccount, + titleStyle: _settingsAccountSubsectionStyle(context), + filled: true, + children: [ + BusyMaxActionRow( + key: const ValueKey('add-account-nextcloud'), + focusNode: entryFocusNode('add-account'), + title: connectingProvider == BusyProvider.nextcloud + ? l10n.waitingForNextcloud + : l10n.addNextcloudAccount, + leading: const Icon(YaruIcons.plus), + onTap: connecting ? null : onAddNextcloud, + ), + BusyMaxActionRow( + title: connectingProvider == BusyProvider.google + ? l10n.waitingForGoogleSignIn + : l10n.addGoogleAccount, + subtitle: googleConfigured ? null : l10n.providerNotConfigured, + leading: const Icon(YaruIcons.plus), + onTap: connecting || !googleConfigured ? null : onAddGoogle, + ), + BusyMaxActionRow( + title: connectingProvider == BusyProvider.microsoft + ? l10n.waitingForMicrosoftSignIn + : l10n.addMicrosoftAccount, + subtitle: microsoftConfigured + ? null + : l10n.providerNotConfigured, + leading: const Icon(YaruIcons.plus), + onTap: connecting || !microsoftConfigured + ? null + : onAddMicrosoft, + ), + BusyMaxActionRow( + title: connectingProvider == BusyProvider.appleICloud + ? l10n.waitingForAppleICloud + : l10n.addAppleICloudAccount, + leading: const Icon(YaruIcons.plus), + onTap: connecting ? null : onAddApple, + ), + if (connecting) + BusyMaxActionRow( + title: l10n.cancelAccountConnection, + leading: const Icon(YaruIcons.window_close), + onTap: onCancelConnection, + ), + ], + ), + _AccountsLoadState(state: ref.watch(accountManagementStreamProvider)), + if (accounts.isNotEmpty) + BusyMaxGroupedList( + title: l10n.connectedAccounts, + titleStyle: _settingsAccountSubsectionStyle(context), + filled: true, + children: [ + for (final account in accounts) + _AccountOverviewRow( + key: ValueKey('account-entry-${account.id}'), + account: account, + focusNode: entryFocusNode('account:${account.id}'), + onOpen: () => onOpenAccount(account.id), + ), + ], + ), + ], + if (selectedAccountId != null && + !accounts.any((a) => a.id == selectedAccountId)) + _AccountsLoadState(state: ref.watch(accountManagementStreamProvider)), + for (final account in accounts.where((a) => a.id == selectedAccountId)) _AccountSettingsGroup( + key: ValueKey('account-detail-${account.id}'), account: account, actions: _AccountManagementCard( account: account, @@ -1745,19 +2120,83 @@ class _AccountManagementSection extends StatelessWidget { onReconnect: connecting || removingAccountIds.contains(account.id) ? null : () => onReconnect(account), + refreshing: refreshingAccountIds.contains(account.id), onRefreshCollections: - account.isSyncEligible && - (account.provider == BusyProvider.appleICloud || - account.provider == BusyProvider.nextcloud) + !refreshingAccountIds.contains(account.id) && + (account.isSyncEligible || + account.provider == BusyProvider.nextcloud && + (account.connectionState == + AccountConnectionState + .permissionChanged || + account.connectionState == + AccountConnectionState + .unsupportedServerProfile)) ? () => onRefreshCollections(account) : null, - onRemoveAccount: () => onRemoveAccount(account), + registrationRecovery: registrationRecoveryIds.contains( + account.id, + ), + onReplaceRegistration: () => onMigrate(account), ), subsections: [ + _AccountSyncStatusCard(account: account), + if (account.calendarsEnabled && + (account.provider == BusyProvider.google || + account.provider == BusyProvider.microsoft)) + _CalendarSettingsCard( + account: account, + sources: [ + for (final source in calendarSources) + if (source.accountId == account.id) source, + ], + onSelected: onCalendarSelected, + onProviderVisibilityChanged: + onCalendarProviderVisibilityChanged, + ), + if (account.provider == BusyProvider.appleICloud || + account.provider == BusyProvider.nextcloud) + _DavCollectionsCard( + account: account, + collections: [ + for (final collection in davCollections) + if (collection.accountId == account.id && + (collection.supportsEvents || + collection.supportsTasks)) + collection, + ], + onEventsSelected: onEventsSelected, + onTasksSelected: onTasksSelected, + ), + if (davConflicts.any( + (conflict) => conflict.accountId == account.id, + )) + _DavConflictsCard( + conflicts: [ + for (final conflict in davConflicts) + if (conflict.accountId == account.id) conflict, + ], + onResolve: onResolveConflict, + ), + if (account.provider == BusyProvider.nextcloud) + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: context.l10n.nextcloudTrash, + leading: const Icon(YaruIcons.trash), + onTap: () => showLinuxNextcloudTrashDialog( + context, + accountId: account.id, + ), + ), + ], + ), + if (account.provider == BusyProvider.google || account.provider == BusyProvider.microsoft || account.provider == BusyProvider.nextcloud) _ContactsSettingsCard( + onReplaceRegistration: () => onMigrate(account), account: account, contactAccount: contactAccounts .where( @@ -1784,6 +2223,8 @@ class _AccountManagementSection extends StatelessWidget { ), ), onEnableRead: () => onEnableContacts(account), + onRetry: onRetryContacts, + onReconnect: onReconnectContacts, onDisable: () { final contact = contactAccounts .where( @@ -1798,25 +2239,6 @@ class _AccountManagementSection extends StatelessWidget { }, onSourceEnabled: onSourceEnabled, ), - if (account.provider == BusyProvider.google || - account.provider == BusyProvider.microsoft) - _RegistrationAccountCard( - account: account, - onMigrate: () => onMigrate(account), - onReconnect: () => onReconnect(account), - ), - if (account.calendarsEnabled && - (account.provider == BusyProvider.google || - account.provider == BusyProvider.microsoft)) - _CalendarSettingsCard( - sources: [ - for (final source in calendarSources) - if (source.accountId == account.id) source, - ], - onSelected: onCalendarSelected, - onProviderVisibilityChanged: - onCalendarProviderVisibilityChanged, - ), if (account.provider == BusyProvider.microsoft && account.isSignedIn) BusyMaxGroupedList( @@ -1845,74 +2267,486 @@ class _AccountManagementSection extends StatelessWidget { ), ], ), - if (account.provider == BusyProvider.appleICloud || - account.provider == BusyProvider.nextcloud) - _DavCollectionsCard( - collections: [ - for (final collection in davCollections) - if (collection.accountId == account.id && - (collection.supportsEvents || - collection.supportsTasks)) - collection, - ], - onEventsSelected: onEventsSelected, - onTasksSelected: onTasksSelected, - ), - if (davConflicts.any( - (conflict) => conflict.accountId == account.id, - )) - _DavConflictsCard( - conflicts: [ - for (final conflict in davConflicts) - if (conflict.accountId == account.id) conflict, - ], - onResolve: onResolveConflict, + if (account.provider == BusyProvider.google || + account.provider == BusyProvider.microsoft) + _RegistrationAccountCard( + account: account, + onReconnect: + connecting || removingAccountIds.contains(account.id) + ? null + : () => onReconnect(account), + onMigrate: () => onMigrate(account), + additionalRows: _contactConnectionOptions(context, account), ), - if (account.provider == BusyProvider.nextcloud) - BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxActionRow( - title: context.l10n.nextcloudTrash, - leading: const Icon(YaruIcons.trash), - onTap: () => showLinuxNextcloudTrashDialog( - context, - accountId: account.id, - ), + if (account.provider == BusyProvider.nextcloud || + account.provider == BusyProvider.appleICloud) + Padding( + padding: const EdgeInsets.only(top: BusyMaxSpacing.lg), + child: YaruExpandable( + key: ValueKey('account-advanced-${account.id}'), + header: Text( + l10n.advancedSection, + style: _settingsSectionHeaderStyle(context), ), - ], + expandIconSemanticLabel: l10n.advancedSection, + child: BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + key: ValueKey('reconnect-account-${account.id}'), + title: l10n.reconnectAccount, + leading: const Icon(YaruIcons.refresh), + onTap: + connecting || + removingAccountIds.contains(account.id) + ? null + : () => onReconnect(account), + ), + ..._contactConnectionOptions(context, account), + ], + ), + ), ), + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + key: ValueKey('remove-account-${account.id}'), + title: removingAccountIds.contains(account.id) + ? l10n.removingAccount + : l10n.removeAccount, + subtitle: l10n.removeAccountDescription, + leading: Icon( + YaruIcons.trash, + color: Theme.of(context).colorScheme.error, + ), + enabled: !removingAccountIds.contains(account.id), + onTap: removingAccountIds.contains(account.id) + ? null + : () => onRemoveAccount(account), + ), + ], + ), ], ), - _ContactsOnlySettingsCard( - accounts: [ - for (final account in contactAccounts) - if (!account.id.startsWith('contacts:')) account, - ], - sources: contactSources, - busyAccountIds: busyContactAccountIds, - onAddCardDav: onAddCardDavContacts, - onAddNextcloud: onAddNextcloudContacts, - onRemove: onRemoveContactsOnly, - onSourceEnabled: onSourceEnabled, + if (selectedAccountId == null) + _ContactsOnlySettingsCard( + key: ValueKey( + 'contact-details-${selectedContactAccountId ?? 'overview'}', + ), + selectedAccountId: selectedContactAccountId, + onOpenAccount: onOpenContactAccount, + entryFocusNode: entryFocusNode, + accountState: ref.watch(busyMaxContactAccountsProvider), + independentIds: ref.watch(independentContactAccountIdsProvider), + sources: contactSources, + busyAccountIds: busyContactAccountIds, + onAddCardDav: onAddCardDavContacts, + onAddNextcloud: onAddNextcloudContacts, + onRemove: onRemoveContactsOnly, + onRetry: onRetryContacts, + onReconnect: onReconnectContacts, + onSourceEnabled: onSourceEnabled, + ), + if (overview) _CalendarImportCard(onImport: onImportIcs), + if (overview) + _CalendarSubscriptionsCard( + subscriptions: subscriptions, + busySubscriptionIds: busySubscriptionIds, + onAdd: onAddSubscription, + onRefresh: onRefreshSubscription, + onRename: onRenameSubscription, + onChangeColor: onChangeSubscriptionColor, + onUnsubscribe: onUnsubscribe, + ), + ], + ); + } +} + +class _AccountsLoadState extends StatelessWidget { + const _AccountsLoadState({required this.state}); + final AsyncValue> state; + @override + Widget build(BuildContext context) => state.hasError + ? BusyMaxActionRow( + title: context.l10n.accountsLoadFailed, + leading: const Icon(YaruIcons.warning), + ) + : state.isLoading + ? BusyMaxActionRow( + title: context.l10n.accountsLoading, + leading: const YaruCircularProgressIndicator(), + ) + : const SizedBox.shrink(); +} + +AccountSettingsFeedback? _primaryAccountFeedback( + BuildContext context, + WidgetRef ref, + AccountEntity account, +) { + final l10n = context.l10n; + if (account.needsReconnect) { + return AccountSettingsFeedback( + AccountSettingsFailure.authorization, + l10n.accountReconnectRequired, + AccountSettingsRecovery.reconnect, + ); + } + if (account.connectionState == AccountConnectionState.permissionChanged) { + return AccountSettingsFeedback( + AccountSettingsFailure.permissions, + l10n.davPermissionChanged, + account.provider == BusyProvider.nextcloud + ? AccountSettingsRecovery.revalidate + : AccountSettingsRecovery.reconnect, + ); + } + if (account.connectionState == + AccountConnectionState.unsupportedServerProfile) { + return AccountSettingsFeedback( + AccountSettingsFailure.configuration, + l10n.davUnsupportedServer, + AccountSettingsRecovery.revalidate, + ); + } + if (account.provider == BusyProvider.nextcloud || + account.provider == BusyProvider.appleICloud) { + final failure = ref + .watch(accountSyncActivityProvider) + .failure(account.id, 'dav'); + if (failure != null) { + return AccountSettingsFeedback.fromError(l10n, failure); + } + final discovery = ref.watch(davAccountDiscoveryProvider(account.id)); + if (discovery.valueOrNull?.lastDiscoveryErrorCode case final code?) { + return AccountSettingsFeedback.fromDavCode(l10n, code); + } + if (discovery.hasError) { + return AccountSettingsFeedback.fromError(l10n, discovery.error!); + } + } + if ((account.provider == BusyProvider.google || + account.provider == BusyProvider.microsoft) && + account.connectionState == + AccountConnectionState.temporarilyUnavailable) { + final activity = ref.watch(accountSyncActivityProvider); + final calendar = activity.failure(account.id, 'calendar'); + final tasks = activity.failure(account.id, 'tasks'); + if (calendar != null || tasks != null) { + // Only the same recorded failure establishes a shared account problem. + // Independent service outcomes keep their own messages and actions. + return calendar != null && identical(calendar, tasks) + ? AccountSettingsFeedback.fromError(l10n, calendar) + : null; + } + } + return account.connectionState == + AccountConnectionState.temporarilyUnavailable + ? AccountSettingsFeedback( + AccountSettingsFailure.temporary, + l10n.accountSyncProblem, + AccountSettingsRecovery.retry, + ) + : null; +} + +class _AccountOverviewRow extends ConsumerWidget { + const _AccountOverviewRow({ + super.key, + required this.account, + required this.focusNode, + required this.onOpen, + }); + final AccountEntity account; + final FocusNode focusNode; + final VoidCallback onOpen; + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final feedback = _primaryAccountFeedback(context, ref, account); + final activity = ref.watch(accountSyncActivityProvider); + final dav = + account.provider == BusyProvider.nextcloud || + account.provider == BusyProvider.appleICloud; + final running = [ + 'dav', + 'calendar', + 'tasks', + ].any((d) => activity.isRunning(account.id, d)); + final loading = dav + ? ref.watch(davCollectionsStreamProvider).isLoading || + ref.watch(davAccountDiscoveryProvider(account.id)).isLoading + : ref.watch(calendarSourcesStreamProvider).isLoading || + ref.watch(accountDomainSyncTimesProvider(account.id)).isLoading; + final inventoryFailed = dav + ? ref.watch(davCollectionsStreamProvider).hasError || + ref.watch(davAccountDiscoveryProvider(account.id)).hasError + : ref.watch(calendarSourcesStreamProvider).hasError || + ref.watch(accountDomainSyncTimesProvider(account.id)).hasError; + final visibilityFailed = + account.provider == BusyProvider.google && + (ref.watch(calendarSourcesStreamProvider).valueOrNull ?? const []) + .where((source) => source.accountId == account.id) + .any((source) { + final state = ref.watch( + calendarVisibilityOperationProvider(source.id), + ); + final operation = state.valueOrNull; + return state.hasError || + operation != null && + (operation.lastErrorCode != null || + !const [ + 'pending', + 'in_progress', + ].contains(operation.state)); + }); + final domainFailure = + [ + 'calendar', + 'tasks', + ].any((d) => activity.failure(account.id, d) != null) || + ref + .watch(davCollectionsStreamProvider) + .valueOrNull + ?.any( + (c) => c.accountId == account.id && c.syncErrorCode != null, + ) == + true || + ref + .watch(calendarSourcesStreamProvider) + .valueOrNull + ?.any( + (s) => + s.accountId == account.id && + s.accessRole == 'unavailable', + ) == + true; + final status = running + ? l10n.traySyncing + : feedback != null + ? switch (feedback.kind) { + AccountSettingsFailure.authorization => l10n.accountReconnectStatus, + AccountSettingsFailure.registration => + l10n.accountRegistrationStatus, + _ => feedback.message, + } + : loading + ? l10n.collectionsLoading + : domainFailure || inventoryFailed || visibilityFailed + ? l10n.accountSyncProblem + : l10n.accountReadyToSync; + return BusyMaxActionRow( + focusNode: focusNode, + title: _accountIdentityLabel(context, account), + titleWidget: Text(_accountIdentityLabel(context, account)), + subtitle: '${_accountProviderLabel(context, account.provider)} · $status', + subtitleWidget: Text( + '${_accountProviderLabel(context, account.provider)} · $status', + ), + leading: Icon( + feedback != null || domainFailure || inventoryFailed || visibilityFailed + ? YaruIcons.warning + : YaruIcons.user, + ), + trailing: Icon( + Directionality.of(context) == TextDirection.rtl + ? YaruIcons.pan_start + : YaruIcons.pan_end, + ), + onTap: onOpen, + ); + } +} + +class _IndependentContactOverviewRow extends ConsumerWidget { + const _IndependentContactOverviewRow({ + super.key, + required this.account, + required this.focusNode, + required this.onOpen, + required this.busy, + }); + final ContactAccount account; + final FocusNode focusNode; + final VoidCallback onOpen; + final bool busy; + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final server = ref.watch(independentContactServerProvider(account.id)); + final status = busy + ? l10n.traySyncing + : server.isLoading + ? l10n.accountsLoading + : !account.enabled + ? l10n.contactsSuggestionsDisabled + : account.errorCode != null + ? _contactErrorMessage(context, account.errorCode!) + : l10n.contactsSuggestionsEnabled; + final identity = [ + account.subject, + if (server.valueOrNull != null) server.valueOrNull.toString(), + if (server.hasError) l10n.oauthSecureStorageUnavailable, + status, + ].where((s) => s.isNotEmpty).join(' · '); + return BusyMaxActionRow( + focusNode: focusNode, + title: account.displayName, + titleWidget: Text(account.displayName), + subtitle: identity, + subtitleWidget: Text(identity), + leading: const Icon(YaruIcons.user), + trailing: Icon( + Directionality.of(context) == TextDirection.rtl + ? YaruIcons.pan_start + : YaruIcons.pan_end, + ), + onTap: onOpen, + ); + } +} + +class _IndependentContactIdentity extends ConsumerWidget { + const _IndependentContactIdentity({required this.account}); + final ContactAccount account; + @override + Widget build(BuildContext context, WidgetRef ref) { + final server = ref.watch(independentContactServerProvider(account.id)); + return server.when( + data: (uri) => + uri == null ? const SizedBox.shrink() : Text(uri.toString()), + loading: () => Text(context.l10n.accountsLoading), + error: (_, _) => Text(context.l10n.oauthSecureStorageUnavailable), + ); + } +} + +class _AccountSyncStatusCard extends ConsumerWidget { + const _AccountSyncStatusCard({required this.account}); + final AccountEntity account; + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final activity = ref.watch(accountSyncActivityProvider); + final times = ref.watch(accountDomainSyncTimesProvider(account.id)); + final dav = + account.provider == BusyProvider.nextcloud || + account.provider == BusyProvider.appleICloud; + final collections = ref.watch(davCollectionsStreamProvider); + final discoveryFailed = + dav && + ref + .watch(davAccountDiscoveryProvider(account.id)) + .valueOrNull + ?.lastDiscoveryErrorCode != + null; + Widget domain(String domain, String label) { + final selected = collections.valueOrNull + ?.where( + (c) => + c.accountId == account.id && + (domain == 'calendar' + ? c.supportsEvents && c.eventsSelected + : c.supportsTasks && c.tasksSelected), + ) + .toList(); + DateTime? last = times.valueOrNull?[domain]; + if (dav && + selected?.isNotEmpty == true && + selected!.every((c) => c.lastSyncAtUtc != null)) { + last = selected + .map((c) => c.lastSyncAtUtc!) + .reduce((a, b) => a.isBefore(b) ? a : b); + } + final running = activity.isRunning(account.id, dav ? 'dav' : domain); + final error = dav ? null : activity.failure(account.id, domain); + final calendarUnavailable = + domain == 'calendar' && + ref + .watch(calendarSourcesStreamProvider) + .valueOrNull + ?.any( + (s) => + s.accountId == account.id && + s.accessRole == 'unavailable', + ) == + true; + final collectionError = + discoveryFailed || + dav && selected?.any((c) => c.syncErrorCode != null) == true; + final loadFailed = + (dav ? collections.hasError : times.hasError) || + domain == 'calendar' && + ref.watch(calendarSourcesStreamProvider).hasError; + final loading = dav ? collections.isLoading : times.isLoading; + final message = running + ? l10n.traySyncing + : error != null + ? AccountSettingsFeedback.fromError(l10n, error).message + : loadFailed + ? l10n.collectionsLoadFailed + : collectionError || calendarUnavailable + ? l10n.cachedSyncError + : loading + ? l10n.collectionsLoading + : l10n.syncNoRecord; + final subtitle = [ + if (running || + loading || + loadFailed || + error != null || + collectionError || + calendarUnavailable || + last == null) + message, + if (last != null) + l10n.davLastSuccessfulSync(_formatDavDateTime(context, last)), + ].join('\n'); + return BusyMaxActionRow( + key: ValueKey('account-sync-$domain-${account.id}'), + title: label, + subtitle: subtitle, + subtitleWidget: Text(subtitle), + leading: Icon( + error != null || collectionError || calendarUnavailable || loadFailed + ? YaruIcons.warning + : domain == 'calendar' + ? YaruIcons.calendar + : YaruIcons.checkmark, ), - _CalendarImportCard(onImport: onImportIcs), - _CalendarSubscriptionsCard( - subscriptions: subscriptions, - busySubscriptionIds: busySubscriptionIds, - onAdd: onAddSubscription, - onRefresh: onRefreshSubscription, - onRename: onRenameSubscription, - onChangeColor: onChangeSubscriptionColor, - onUnsubscribe: onUnsubscribe, + ); + } + + return Padding( + padding: const EdgeInsets.only(top: BusyMaxSpacing.lg), + child: YaruExpandable( + key: ValueKey('account-sync-details-${account.id}'), + header: Text( + l10n.accountSyncDetails, + style: _settingsSectionHeaderStyle(context), ), - ], + expandIconSemanticLabel: l10n.accountSyncDetails, + child: BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.davConnectionState, + subtitle: _accountConnectionStateLabel(context, account), + ), + if (account.calendarsEnabled) domain('calendar', l10n.calendar), + if (account.isTaskCapable) domain('tasks', l10n.tasks), + ], + ), + ), ); } } class _AccountSettingsGroup extends StatelessWidget { const _AccountSettingsGroup({ + super.key, required this.account, required this.actions, required this.subsections, @@ -1924,47 +2758,70 @@ class _AccountSettingsGroup extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(top: BusyMaxSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: BusyMaxSpacing.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - header: true, - child: Text( - key: ValueKey('settings-account-heading-${account.id}'), - _accountProviderLabel(context, account.provider), - style: _settingsSectionHeaderStyle(context), - ), - ), - const SizedBox(height: BusyMaxSpacing.xs), - Text( - _accountIdentityLabel(context, account), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + key: ValueKey('settings-account-heading-${account.id}'), + _accountProviderLabel(context, account.provider), + style: _settingsSectionHeaderStyle(context), ), - actions, - if (subsections.isNotEmpty) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: subsections, - ), - ], - ), + ), + const SizedBox(height: BusyMaxSpacing.xs), + Text(_accountIdentityLabel(context, account)), + actions, + ...subsections, + ], ); } } -class _ContactsSettingsCard extends StatelessWidget { +AccountSettingsRecovery _contactRecovery(String? code) { + if (code == 'reconnect-required' || code == 'linked-parent-removed') { + return AccountSettingsRecovery.reconnect; + } + final parts = code?.split(':') ?? const []; + if (parts.length < 2) return AccountSettingsRecovery.retry; + if (parts.first == 'contacts') { + return const ['authentication', 'permission'].contains(parts[1]) + ? AccountSettingsRecovery.reconnect + : AccountSettingsRecovery.retry; + } + if (parts.first == 'oauth') { + final kind = OAuthFailureKind.values + .where((k) => k.name == parts[1]) + .firstOrNull; + return switch (kind) { + OAuthFailureKind.registrationUnavailable || + OAuthFailureKind.clientRejected || + OAuthFailureKind.configuration => + AccountSettingsRecovery.replaceRegistration, + OAuthFailureKind.expiredGrant || + OAuthFailureKind.permission || + OAuthFailureKind.wrongAccount || + OAuthFailureKind.identityUnverified => AccountSettingsRecovery.reconnect, + _ => AccountSettingsRecovery.retry, + }; + } + return AccountSettingsRecovery.retry; +} + +String _contactErrorMessage(BuildContext context, String code) { + final parts = code.split(':'); + if (parts.length >= 2 && + (parts.first == 'contacts' && parts[1] == 'offline' || + parts.first == 'oauth' && + const ['network', 'timeout'].contains(parts[1]))) { + return context.l10n.accountServiceUnreachable; + } + return _contactRecovery(code) == AccountSettingsRecovery.replaceRegistration + ? context.l10n.accountRegistrationStatus + : localizedContactsStatus(context.l10n, code); +} + +class _ContactsSettingsCard extends ConsumerWidget { const _ContactsSettingsCard({ required this.account, required this.contactAccount, @@ -1973,6 +2830,9 @@ class _ContactsSettingsCard extends StatelessWidget { required this.onEnableRead, required this.onDisable, required this.onSourceEnabled, + required this.onRetry, + required this.onReconnect, + required this.onReplaceRegistration, }); final AccountEntity account; @@ -1980,91 +2840,324 @@ class _ContactsSettingsCard extends StatelessWidget { final List sources; final bool busy; final VoidCallback onEnableRead; + final VoidCallback onReplaceRegistration; final VoidCallback onDisable; final Future Function(String sourceKey, bool enabled) onSourceEnabled; + final void Function(ContactAccount) onRetry, onReconnect; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final contact = contactAccount; final l10n = context.l10n; + final contactState = ref.watch(busyMaxContactAccountsProvider); + final sourceState = ref.watch(busyMaxContactSourceSettingsProvider); + if (contactState.isLoading && !contactState.hasValue) { + return BusyMaxGroupedList( + title: l10n.contactsTitle, + filled: true, + children: [BusyMaxActionRow(title: l10n.accountsLoading)], + ); + } + if (contactState.hasError && !contactState.hasValue) { + return BusyMaxGroupedList( + title: l10n.contactsTitle, + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.contactsSyncFailed, + onTap: () => ref.invalidate(busyMaxContactAccountsProvider), + ), + ], + ); + } + final recovery = _contactRecovery(contact?.errorCode); + final needsRecovery = + contact?.enabled == true && contact?.errorCode != null; return BusyMaxGroupedList( title: l10n.contactsTitle, titleStyle: _settingsAccountSubsectionStyle(context), - description: l10n.contactsLinkedDescription, + description: l10n.accountContactSuggestions, filled: true, - children: contact == null - ? [ - BusyMaxActionRow( - key: ValueKey('enable-contacts-read-${account.id}'), - title: l10n.contactsEnableSuggestions, - subtitle: l10n.contactsReadPermissionDescription, - leading: const Icon(YaruIcons.user), - onTap: busy ? null : onEnableRead, - ), - ] - : [ - BusyMaxActionRow( - title: contact.errorCode == null - ? l10n.contactsSuggestionsEnabled - : l10n.contactsNeedsAttention, - subtitle: l10n.contactsReadOnlyUse, - leading: Icon( - contact.errorCode == null - ? YaruIcons.checkmark - : YaruIcons.warning, - ), + children: [ + if (busy) + BusyMaxActionRow( + title: l10n.traySyncing, + leading: const YaruCircularProgressIndicator(), + ), + if (contact == null) + BusyMaxActionRow( + key: ValueKey('enable-contacts-read-${account.id}'), + title: l10n.contactsEnableSuggestions, + subtitle: account.provider == BusyProvider.nextcloud + ? l10n.contactsReadOnlyUse + : l10n.contactsReadPermissionDescription, + leading: const Icon(YaruIcons.user), + onTap: busy ? null : onEnableRead, + ) + else ...[ + BusyMaxSwitchRow( + key: ValueKey('enable-contacts-read-${account.id}'), + title: l10n.contactsEnableSuggestions, + subtitle: l10n.contactsReadOnlyUse, + value: contact.enabled, + enabled: !busy, + leading: const Icon(YaruIcons.user), + onChanged: (enabled) => enabled ? onEnableRead() : onDisable(), + ), + if (needsRecovery) + BusyMaxActionRow( + title: _contactErrorMessage(context, contact.errorCode!), + titleWidget: Text( + _contactErrorMessage(context, contact.errorCode!), ), - BusyMaxActionRow( + leading: const Icon(YaruIcons.warning), + trailing: BusyMaxPushButton.standard( key: ValueKey('reconnect-contacts-${account.id}'), - title: l10n.connectAccountAction, - leading: const Icon(YaruIcons.sync), - onTap: busy ? null : onEnableRead, - ), - for (final setting in sources) - _ContactSourceSettingsRow( - setting: setting, - onChanged: (enabled) => - onSourceEnabled(setting.source.key, enabled), - ), - BusyMaxActionRow( - key: ValueKey('disable-contacts-${account.id}'), - title: l10n.contactsDisableForAccount, - leading: Icon( - YaruIcons.trash, - color: Theme.of(context).colorScheme.error, + onPressed: busy + ? null + : recovery == AccountSettingsRecovery.replaceRegistration + ? onReplaceRegistration + : recovery == AccountSettingsRecovery.reconnect + ? () => onReconnect(contact) + : () => onRetry(contact), + child: Text( + recovery == AccountSettingsRecovery.replaceRegistration + ? l10n.registrationReplace + : recovery == AccountSettingsRecovery.reconnect + ? l10n.reconnectAccount + : l10n.contactsRetrySync, ), - destructive: true, - onTap: busy ? null : onDisable, ), - ], + ), + if (sourceState.hasError) + BusyMaxActionRow( + title: l10n.contactsSourcesUnavailable, + trailing: BusyMaxPushButton.standard( + onPressed: () => + ref.invalidate(busyMaxContactSourceSettingsProvider), + child: Text(l10n.retry), + ), + ), + if (sources.isEmpty && !needsRecovery && !sourceState.hasError) + BusyMaxActionRow( + title: sourceState.isLoading + ? l10n.collectionsLoading + : l10n.contactsNoAddressBooks, + ), + for (final setting in sources) + _ContactSourceSettingsRow( + setting: setting, + onChanged: (enabled) => + onSourceEnabled(setting.source.key, enabled), + ), + ], + ], ); } } -class _ContactsOnlySettingsCard extends StatelessWidget { +class _ContactsOnlySettingsCard extends ConsumerWidget { const _ContactsOnlySettingsCard({ - required this.accounts, + super.key, + required this.selectedAccountId, + required this.onOpenAccount, + required this.entryFocusNode, + required this.accountState, + required this.independentIds, required this.sources, required this.busyAccountIds, required this.onAddCardDav, required this.onAddNextcloud, required this.onRemove, required this.onSourceEnabled, + required this.onRetry, + required this.onReconnect, }); - - final List accounts; + final String? selectedAccountId; + final ValueChanged onOpenAccount; + final FocusNode Function(String) entryFocusNode; + final AsyncValue> accountState; + final AsyncValue> independentIds; final List sources; final Set busyAccountIds; - final VoidCallback onAddCardDav; - final VoidCallback onAddNextcloud; - final void Function(ContactAccount account) onRemove; - final Future Function(String sourceKey, bool enabled) onSourceEnabled; + final VoidCallback onAddCardDav, onAddNextcloud; + final void Function(ContactAccount) onRemove, onRetry, onReconnect; + final Future Function(String, bool) onSourceEnabled; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; + // Cached values remain useful during refresh and failure. An unavailable + // inventory still has its own status; it is never a successful empty list. + final accounts = [ + for (final account in accountState.valueOrNull ?? []) + if (independentIds.valueOrNull?.contains(account.id) == true) account, + ]; + final accountsFailed = accountState.hasError && !accountState.isLoading; + final independentIdsFailed = + independentIds.hasError && !independentIds.isLoading; + final inventoryFailed = accountsFailed || independentIdsFailed; + final inventoryLoading = accountState.isLoading || independentIds.isLoading; + void retryInventory() { + if (accountsFailed) { + ref.invalidate(busyMaxContactAccountsProvider); + } + if (independentIdsFailed) { + ref.invalidate(independentContactAccountIdsProvider); + } + } + + Widget inventoryFailure() => BusyMaxActionRow( + key: const ValueKey('contacts-inventory-error'), + title: l10n.contactsAccountsUnavailable, + leading: const Icon(YaruIcons.warning), + trailing: BusyMaxPushButton.standard( + key: const ValueKey('contacts-inventory-retry'), + onPressed: retryInventory, + child: Text(l10n.retry), + ), + ); + if (selectedAccountId != null) { + final sourceState = ref.watch(busyMaxContactSourceSettingsProvider); + final account = accounts + .where((a) => a.id == selectedAccountId) + .firstOrNull; + if (account == null) { + return inventoryFailed + ? inventoryFailure() + : BusyMaxActionRow(title: l10n.accountsLoading); + } + final busy = busyAccountIds.contains(account.id); + final recovery = _contactRecovery(account.errorCode); + final reconnect = + !account.enabled || recovery != AccountSettingsRecovery.retry; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + account.displayName, + style: _settingsSectionHeaderStyle(context), + ), + ), + const SizedBox(height: BusyMaxSpacing.xs), + Text(account.subject), + _IndependentContactIdentity(account: account), + BusyMaxGroupedList( + title: l10n.contactsTitle, + description: l10n.contactsOnlyDescription, + filled: true, + children: [ + if (busy) + BusyMaxActionRow( + title: l10n.traySyncing, + leading: const YaruCircularProgressIndicator(), + ), + if (!account.enabled || account.errorCode != null) + BusyMaxActionRow( + title: account.errorCode == null + ? l10n.contactsSuggestionsDisabled + : _contactErrorMessage(context, account.errorCode!), + titleWidget: Text( + account.errorCode == null + ? l10n.contactsSuggestionsDisabled + : _contactErrorMessage(context, account.errorCode!), + ), + leading: Icon( + account.errorCode == null + ? YaruIcons.user + : YaruIcons.warning, + ), + trailing: BusyMaxPushButton.standard( + key: ValueKey('contact-account-recovery-${account.id}'), + onPressed: busy + ? null + : reconnect + ? () => onReconnect(account) + : () => onRetry(account), + child: Text( + reconnect + ? l10n.reconnectAccount + : l10n.contactsRetrySync, + ), + ), + ), + for (final setting in sources.where( + (s) => s.source.accountId == account.id, + )) + _ContactSourceSettingsRow( + setting: setting, + onChanged: (enabled) => + onSourceEnabled(setting.source.key, enabled), + ), + if (sourceState.hasError) + BusyMaxActionRow( + title: l10n.contactsSourcesUnavailable, + trailing: BusyMaxPushButton.standard( + onPressed: () => + ref.invalidate(busyMaxContactSourceSettingsProvider), + child: Text(l10n.retry), + ), + ), + if (account.enabled && + account.errorCode == null && + !sourceState.hasError && + !sources.any((s) => s.source.accountId == account.id)) + BusyMaxActionRow( + title: sourceState.isLoading + ? l10n.collectionsLoading + : l10n.contactsNoAddressBooks, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: BusyMaxSpacing.lg), + child: YaruExpandable( + header: Text( + l10n.advancedSection, + style: _settingsSectionHeaderStyle(context), + ), + key: ValueKey('contact-account-advanced-${account.id}'), + expandIconSemanticLabel: l10n.advancedSection, + child: BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: l10n.reconnectAccount, + leading: const Icon(YaruIcons.refresh), + onTap: busy ? null : () => onReconnect(account), + ), + BusyMaxActionRow( + title: l10n.contactsRetrySync, + leading: const Icon(YaruIcons.sync), + onTap: busy || !account.enabled + ? null + : () => onRetry(account), + ), + ], + ), + ), + ), + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + key: ValueKey('remove-contacts-only-${account.id}'), + title: l10n.removeAction, + leading: Icon( + YaruIcons.trash, + color: Theme.of(context).colorScheme.error, + ), + onTap: busy ? null : () => onRemove(account), + ), + ], + ), + ], + ); + } return BusyMaxGroupedList( - title: l10n.contactsTitle, + title: l10n.contactsIndependentAccounts, titleStyle: _settingsSectionHeaderStyle(context), description: l10n.contactsOnlyDescription, filled: true, @@ -2081,32 +3174,20 @@ class _ContactsOnlySettingsCard extends StatelessWidget { leading: const Icon(YaruIcons.plus), onTap: onAddNextcloud, ), - for (final account in accounts) ...[ + if (inventoryLoading) BusyMaxActionRow( - title: account.displayName, - subtitle: l10n.contactsReadOnlyUse, - leading: const Icon(YaruIcons.user), + key: const ValueKey('contacts-inventory-loading'), + title: l10n.accountsLoading, ), - for (final setting in sources) - if (setting.source.accountId == account.id) - _ContactSourceSettingsRow( - setting: setting, - onChanged: (enabled) => - onSourceEnabled(setting.source.key, enabled), - ), - BusyMaxActionRow( - key: ValueKey('remove-contacts-only-${account.id}'), - title: l10n.removeAction, - leading: Icon( - YaruIcons.trash, - color: Theme.of(context).colorScheme.error, - ), - destructive: true, - onTap: busyAccountIds.contains(account.id) - ? null - : () => onRemove(account), + if (inventoryFailed) inventoryFailure(), + for (final account in accounts) + _IndependentContactOverviewRow( + key: ValueKey('contact-account-entry-${account.id}'), + account: account, + busy: busyAccountIds.contains(account.id), + focusNode: entryFocusNode('contacts:${account.id}'), + onOpen: () => onOpenAccount(account.id), ), - ], ], ); } @@ -2147,7 +3228,7 @@ class _ContactSourceSettingsRowState extends State<_ContactSourceSettingsRow> { if (mounted) setState(() => _value = value); } on Object { if (mounted) { - setState(() => _error = 'Could not save contact source preference'); + setState(() => _error = context.l10n.contactsPreferenceFailed); } } finally { if (mounted) setState(() => _pending = false); @@ -2181,242 +3262,394 @@ TextStyle? _settingsAccountSubsectionStyle(BuildContext context) { ); } -class _CalendarSettingsCard extends StatelessWidget { +class _CalendarSettingsCard extends ConsumerWidget { const _CalendarSettingsCard({ required this.sources, + required this.account, required this.onSelected, required this.onProviderVisibilityChanged, }); final List sources; + final AccountEntity account; final void Function(CalendarSourceEntity source, bool selected) onSelected; final void Function(CalendarSourceEntity source, bool visible) onProviderVisibilityChanged; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; + final state = ref.watch(calendarSourcesStreamProvider); final showsProviderVisibility = sources.any( - (source) => source.capabilities.canChangeProviderVisibility, + (source) => source.provider == BusyProvider.google, ); return BusyMaxGroupedList( title: l10n.calendars, titleStyle: _settingsAccountSubsectionStyle(context), filled: true, + description: showsProviderVisibility ? l10n.googleVisibilityHelp : null, children: sources.isEmpty ? [ BusyMaxActionRow( - title: l10n.noCalendarsSynced, + title: _collectionEmptyMessage( + context, + ref, + account: account, + loading: ref.watch(calendarSourcesStreamProvider).isLoading, + failed: ref.watch(calendarSourcesStreamProvider).hasError, + ), leading: const Icon(YaruIcons.calendar), ), ] : [ - _CalendarSettingsColumnHeader( - showsProviderVisibility: showsProviderVisibility, - ), + if (state.hasError) + BusyMaxActionRow( + title: l10n.collectionsLoadFailed, + titleWidget: Text(l10n.collectionsLoadFailed), + leading: const Icon(YaruIcons.warning), + ), + if (state.isLoading) + BusyMaxActionRow( + title: l10n.collectionsLoading, + leading: const YaruCircularProgressIndicator(), + ), for (final source in sources) _CalendarSettingsRow( + key: ValueKey('settings-calendar-row-${source.id}'), source: source, - showsProviderVisibility: showsProviderVisibility, - onSelected: onSelected, - onProviderVisibilityChanged: onProviderVisibilityChanged, - ), - ], - ); - } -} - -const _calendarSettingsColumnWidth = 92.0; - -class _CalendarSettingsColumnHeader extends StatelessWidget { - const _CalendarSettingsColumnHeader({required this.showsProviderVisibility}); - - final bool showsProviderVisibility; - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - final style = Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ); - return Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - BusyMaxSpacing.md, - BusyMaxSpacing.sm, - BusyMaxSpacing.md, - BusyMaxSpacing.xs, - ), - child: Row( - children: [ - const Expanded(child: SizedBox()), - _CalendarSettingsColumnLabel( - key: const ValueKey('settings-calendar-column-schedule'), - label: l10n.scheduleSettings, - style: style, - ), - if (showsProviderVisibility) - _CalendarSettingsColumnLabel( - key: const ValueKey('settings-calendar-column-provider'), - label: l10n.googleProvider, - style: style, - ), - ], - ), - ); - } -} - -class _CalendarSettingsColumnLabel extends StatelessWidget { - const _CalendarSettingsColumnLabel({ - super.key, - required this.label, - required this.style, - }); - - final String label; - final TextStyle? style; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: _calendarSettingsColumnWidth, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: style, - ), + onSelected: onSelected, + onProviderVisibilityChanged: onProviderVisibilityChanged, + ), + ], ); } } -class _CalendarSettingsRow extends ConsumerWidget { +class _CalendarSettingsRow extends ConsumerStatefulWidget { const _CalendarSettingsRow({ + super.key, required this.source, - required this.showsProviderVisibility, required this.onSelected, required this.onProviderVisibilityChanged, }); - final CalendarSourceEntity source; - final bool showsProviderVisibility; - final void Function(CalendarSourceEntity source, bool selected) onSelected; - final void Function(CalendarSourceEntity source, bool visible) - onProviderVisibilityChanged; + final void Function(CalendarSourceEntity, bool) onSelected; + final void Function(CalendarSourceEntity, bool) onProviderVisibilityChanged; + @override + ConsumerState<_CalendarSettingsRow> createState() => + _CalendarSettingsRowState(); +} +class _CalendarSettingsRowState extends ConsumerState<_CalendarSettingsRow> { + bool _expanded = false; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { + final source = widget.source; final l10n = context.l10n; - final canChangeProviderVisibility = - source.capabilities.canChangeProviderVisibility; - return Semantics( - container: true, - label: source.summary, - explicitChildNodes: true, - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - BusyMaxSpacing.md, - BusyMaxSpacing.xs, - BusyMaxSpacing.md, - BusyMaxSpacing.xs, + final google = source.provider == BusyProvider.google; + final operationState = google + ? ref.watch(calendarVisibilityOperationProvider(source.id)) + : null; + final pending = operationState?.valueOrNull; + final failed = + pending != null && + (pending.lastErrorCode != null || + !const ['pending', 'in_progress'].contains(pending.state)); + var confirmedHidden = source.hidden; + if (failed) { + final request = jsonDecode(pending.requestJson) as Map; + final previous = request[calendarPatchPreviousValuesKey]; + final baseline = jsonDecode(pending.baselineRawJson ?? '{}') as Map; + confirmedHidden = previous is Map && previous['hidden'] is bool + ? previous['hidden'] as bool + : baseline['hidden'] == true; + } + final shared = + MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ) != + null || + source.dataOwner != null && + source.authenticatedAccountEmail != null && + source.dataOwner!.toLowerCase() != + source.authenticatedAccountEmail!.toLowerCase(); + final details = [ + if (shared) l10n.sharedCollection, + if (source.readOnly) l10n.readOnlySharedCollection, + if (source.accessRole == 'unavailable') l10n.sharedCalendarUnavailable, + ].join(' · '); + final localValue = source.selected && !source.hidden; + void selectLocal(bool value) => widget.onSelected(source, value); + Widget localVisibilityControl() => Semantics( + label: '${l10n.showInBusyMax} · ${source.summary}', + child: BusyMaxYaruFocusBorder( + builder: (context, focusNode) => YaruSwitch( + key: ValueKey('settings-calendar-schedule-${source.id}'), + value: localValue, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: source.hidden ? null : selectLocal, ), - child: Row( - children: [ - _DavCollectionIndicator(color: source.backgroundColor), - const SizedBox(width: BusyMaxSpacing.md), - Expanded( - child: Text( - source.summary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + ), + ); + final localRow = BusyMaxActionRow( + title: google ? l10n.showInBusyMax : source.summary, + titleWidget: Text(google ? l10n.showInBusyMax : source.summary), + subtitle: google || details.isEmpty ? null : details, + subtitleWidget: google || details.isEmpty ? null : Text(details), + leading: google + ? null + : _DavCollectionIndicator(color: source.backgroundColor), + onTap: source.hidden ? null : () => selectLocal(!localValue), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + localVisibilityControl(), + if (source.provider == BusyProvider.microsoft && + source.capabilities.removalMode == + CalendarRemovalMode.removeFromList) + YaruIconButton( + key: ValueKey('settings-calendar-remove-${source.id}'), + tooltip: '${l10n.removeFromMyCalendars} · ${source.summary}', + icon: const Icon(YaruIcons.trash), + onPressed: () => _removeSharedCalendar(context), ), - _CalendarSettingsSwitchCell( - message: l10n.showInSchedule, - child: BusyMaxYaruFocusBorder( - builder: (context, focusNode) => YaruSwitch( - key: ValueKey('settings-calendar-schedule-${source.id}'), - value: source.selected && !source.hidden, - focusNode: focusNode, - hasFocusBorder: false, - onChanged: source.hidden - ? null - : (selected) => onSelected(source, selected), - ), - ), + ], + ), + ); + if (!google) return localRow; + final busyVisibility = + operationState!.isLoading && !operationState.hasValue; + final visibilityLoadFailed = operationState.hasError; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + expanded: _expanded, + child: YaruExpandable( + key: ValueKey('settings-calendar-expander-${source.id}'), + isExpanded: _expanded, + onChange: (value) => setState(() => _expanded = value), + expandIconSemanticLabel: source.summary, + header: BusyMaxActionRow( + title: source.summary, + titleWidget: Text(source.summary), + subtitle: details.isEmpty ? null : details, + subtitleWidget: details.isEmpty ? null : Text(details), + leading: _DavCollectionIndicator(color: source.backgroundColor), ), - if (showsProviderVisibility) - _CalendarSettingsSwitchCell( - message: l10n.visibility, - child: BusyMaxYaruFocusBorder( - builder: (context, focusNode) => YaruSwitch( - key: ValueKey('settings-calendar-provider-${source.id}'), - value: !source.hidden, - focusNode: focusNode, - hasFocusBorder: false, - onChanged: canChangeProviderVisibility - ? (visible) => - onProviderVisibilityChanged(source, visible) - : null, + child: Column( + children: [ + localRow, + BusyMaxActionRow( + title: l10n.showInGoogleCalendarList, + titleWidget: Text(l10n.showInGoogleCalendarList), + subtitle: source.primaryCalendar + ? l10n.primaryCalendarVisibilityExplanation + : null, + subtitleWidget: source.primaryCalendar + ? Text(l10n.primaryCalendarVisibilityExplanation) + : null, + trailing: Semantics( + label: + '${l10n.showInGoogleCalendarList} · ${source.summary}', + child: BusyMaxYaruFocusBorder( + builder: (context, focusNode) => YaruSwitch( + key: ValueKey( + 'settings-calendar-provider-${source.id}', + ), + value: !confirmedHidden, + focusNode: focusNode, + hasFocusBorder: false, + onChanged: + source.capabilities.canChangeProviderVisibility && + !failed && + !busyVisibility && + !visibilityLoadFailed + ? (visible) => widget.onProviderVisibilityChanged( + source, + visible, + ) + : null, + ), + ), ), ), - ), - if (source.provider == BusyProvider.microsoft && - source.capabilities.removalMode == - CalendarRemovalMode.removeFromList) - YaruIconButton( - key: ValueKey('settings-calendar-remove-${source.id}'), - tooltip: l10n.removeFromMyCalendars, - icon: const Icon(YaruIcons.trash), - onPressed: () async { - final confirmed = await showBusyMaxConfirm( - context, - title: l10n.removeFromMyCalendars, - message: l10n.removeOpenedSharedCalendarConfirmation( - source.summary, - ), - confirmLabel: l10n.removeAction, - ); - if (!confirmed || !context.mounted) return; - try { - await ref - .read(calendarRepositoryProvider) - .deleteLocalSource(source.id); - } on Object catch (error) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.calendarUpdateFailed('$error')), - ), - ); - } - } - }, - ), - ], + ], + ), + ), ), - ), + if (busyVisibility) + BusyMaxActionRow( + title: l10n.collectionsLoading, + leading: const YaruCircularProgressIndicator(), + ), + if (visibilityLoadFailed) + BusyMaxActionRow( + title: l10n.accountSyncProblem, + trailing: BusyMaxPushButton.standard( + onPressed: () => ref.invalidate( + calendarVisibilityOperationProvider(source.id), + ), + child: Text(l10n.retry), + ), + ), + if (pending != null) + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + BusyMaxSpacing.md, + BusyMaxSpacing.xs, + BusyMaxSpacing.md, + BusyMaxSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + failed + ? l10n.calendarRemoteFailed + : l10n.calendarRemotePending, + style: Theme.of(context).textTheme.bodySmall, + ), + if (failed) + _CalendarVisibilityRecoveryActions( + key: ValueKey('visibility-recovery-${pending.id}'), + source: source, + operation: pending, + ), + ], + ), + ), + ], + ); + } + + Future _removeSharedCalendar(BuildContext context) async { + final source = widget.source; + final l10n = context.l10n; + final confirmed = await showBusyMaxConfirm( + context, + title: l10n.removeFromMyCalendars, + message: l10n.removeOpenedSharedCalendarConfirmation(source.summary), + confirmLabel: l10n.removeAction, ); + if (!confirmed || !context.mounted) return; + try { + await ref.read(calendarRepositoryProvider).deleteLocalSource(source.id); + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AccountSettingsFeedback.fromError(l10n, error).message, + ), + ), + ); + } + } } } -class _CalendarSettingsSwitchCell extends StatelessWidget { - const _CalendarSettingsSwitchCell({ - required this.message, - required this.child, +class _CalendarVisibilityRecoveryActions extends ConsumerStatefulWidget { + const _CalendarVisibilityRecoveryActions({ + super.key, + required this.source, + required this.operation, }); + final CalendarSourceEntity source; + final PendingOp operation; + + @override + ConsumerState<_CalendarVisibilityRecoveryActions> createState() => + _CalendarVisibilityRecoveryActionsState(); +} - final String message; - final Widget child; +class _CalendarVisibilityRecoveryActionsState + extends ConsumerState<_CalendarVisibilityRecoveryActions> { + bool _busy = false; + + Future _resolve({required bool discard}) async { + if (_busy) return; + setState(() => _busy = true); + final l10n = context.l10n; + try { + // Discard restores the entire merged patch. List every affected setting, + // including personal settings queued alongside visibility. + final request = jsonDecode(widget.operation.requestJson) as Map; + final settings = [ + if (request.containsKey('summary')) l10n.title, + if (request.containsKey('backgroundColor') || + request.containsKey('foregroundColor') || + request.containsKey('colorId')) + l10n.calendarColor, + if (request.containsKey('hidden')) l10n.showInGoogleCalendarList, + ]; + if (discard && + !await showBusyMaxConfirm( + context, + title: l10n.discardPendingOperation, + message: l10n.discardCalendarSettingsConfirmation( + widget.source.summary, + settings.map((setting) => '• $setting').join('\n'), + ), + confirmLabel: l10n.discard, + )) { + return; + } + if (!mounted) return; + final service = ref.read( + pendingOpResolutionServiceForAccountProvider(widget.source.accountId), + ); + if (discard) { + await service.discard(widget.operation.id); + } else { + await service.retryNow(widget.operation.id); + } + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AccountSettingsFeedback.fromError(l10n, error).message, + ), + ), + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } @override Widget build(BuildContext context) { - return SizedBox( - width: _calendarSettingsColumnWidth, - child: Center( - child: Tooltip(message: message, child: child), + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.only(top: BusyMaxSpacing.sm), + child: Wrap( + spacing: BusyMaxSpacing.sm, + runSpacing: BusyMaxSpacing.sm, + children: [ + BusyMaxPushButton.standard( + key: ValueKey( + 'settings-calendar-visibility-retry-${widget.source.id}', + ), + onPressed: _busy ? null : () => _resolve(discard: false), + child: Text( + l10n.retry, + semanticsLabel: '${l10n.retry} · ${widget.source.summary}', + ), + ), + BusyMaxPushButton.standard( + key: ValueKey( + 'settings-calendar-visibility-discard-${widget.source.id}', + ), + onPressed: _busy ? null : () => _resolve(discard: true), + child: Text( + l10n.discard, + semanticsLabel: '${l10n.discard} · ${widget.source.summary}', + ), + ), + ], ), ); } @@ -2438,7 +3671,7 @@ class _CalendarImportCard extends StatelessWidget { BusyMaxActionRow( key: const ValueKey('import-ics-file'), title: context.l10n.importIcsFile, - leading: const Icon(Icons.file_open_outlined), + leading: const Icon(YaruIcons.document_open), onTap: onImport, ), ], @@ -2498,7 +3731,7 @@ class _CalendarSubscriptionsCard extends StatelessWidget { BusyMaxActionRow( title: l10n.subscriptionReadOnly, subtitle: _subscriptionTiming(context, subscription), - leading: const Icon(Icons.lock_outline), + leading: const Icon(YaruIcons.lock), ), BusyMaxActionRow( title: subscription.lastFailureCode == null @@ -2524,14 +3757,14 @@ class _CalendarSubscriptionsCard extends StatelessWidget { ), BusyMaxActionRow( title: l10n.rename, - leading: const Icon(Icons.edit_outlined), + leading: const Icon(YaruIcons.pen), onTap: busySubscriptionIds.contains(subscription.id) ? null : () => onRename(subscription), ), BusyMaxActionRow( title: l10n.calendarColor, - leading: const Icon(Icons.palette_outlined), + leading: const Icon(YaruIcons.color_select), onTap: busySubscriptionIds.contains(subscription.id) ? null : () => onChangeColor(subscription), @@ -2890,237 +4123,337 @@ Future showAddCalendarSubscriptionFlow( } } -class _AccountManagementCard extends StatelessWidget { +class _AccountManagementCard extends ConsumerWidget { const _AccountManagementCard({ required this.account, required this.removing, + required this.refreshing, required this.onReconnect, required this.onRefreshCollections, - required this.onRemoveAccount, + required this.registrationRecovery, + required this.onReplaceRegistration, }); - final AccountEntity account; - final bool removing; - final VoidCallback? onReconnect; - final VoidCallback? onRefreshCollections; - final VoidCallback onRemoveAccount; + final bool removing, refreshing, registrationRecovery; + final VoidCallback? onReconnect, onRefreshCollections; + final VoidCallback onReplaceRegistration; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; - return BusyMaxGroupedList( - filled: true, - children: [ - if (account.provider == BusyProvider.nextcloud) - BusyMaxActionRow( - title: l10n.nextcloudProvider, - subtitle: l10n.nextcloudServerHost( - Uri.tryParse(account.authority)?.host ?? account.authority, - ), - leading: const Icon(Icons.cloud_outlined), + final primary = registrationRecovery + ? AccountSettingsFeedback( + AccountSettingsFailure.registration, + l10n.accountRegistrationStatus, + AccountSettingsRecovery.replaceRegistration, + ) + : _primaryAccountFeedback(context, ref, account); + final activity = ref.watch(accountSyncActivityProvider); + VoidCallback? recovery(AccountSettingsRecovery action, {String? domain}) => + switch (action) { + AccountSettingsRecovery.reconnect => onReconnect, + AccountSettingsRecovery.replaceRegistration => onReplaceRegistration, + AccountSettingsRecovery.revalidate => + account.provider == BusyProvider.nextcloud + ? onRefreshCollections + : onReconnect, + AccountSettingsRecovery.retry => + domain == null + ? onRefreshCollections + : () async { + try { + final operations = ref.read( + accountSyncOperationsProvider, + ); + if (domain == 'calendar') { + await operations.syncCalendar(account.id, full: false); + } else { + await operations.syncTasks(account.id, full: false); + } + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AccountSettingsFeedback.fromError( + context.l10n, + error, + ).message, + ), + ), + ); + } + } + }, + }; + String recoveryLabel(AccountSettingsRecovery action) => switch (action) { + AccountSettingsRecovery.reconnect => l10n.reconnectAccount, + AccountSettingsRecovery.replaceRegistration => l10n.registrationReplace, + AccountSettingsRecovery.revalidate => + account.provider == BusyProvider.nextcloud + ? l10n.revalidateAccount + : l10n.reconnectAccount, + AccountSettingsRecovery.retry => l10n.retry, + }; + Widget failureRow(AccountSettingsFeedback feedback, {String? domain}) => + BusyMaxActionRow( + key: ValueKey( + domain == null + ? 'account-primary-feedback-${account.id}' + : 'account-domain-feedback-$domain-${account.id}', ), - if (account.provider == BusyProvider.appleICloud || - account.provider == BusyProvider.nextcloud) - BusyMaxActionRow( - title: l10n.davConnectionState, - subtitle: _accountConnectionStateLabel(context, account), - leading: Icon( - account.hasConnectionIssue - ? YaruIcons.warning - : YaruIcons.checkmark, + title: domain == null + ? feedback.message + : domain == 'calendar' + ? l10n.calendar + : l10n.tasks, + titleWidget: domain == null ? Text(feedback.message) : null, + subtitle: domain == null ? null : feedback.message, + subtitleWidget: domain == null ? null : Text(feedback.message), + leading: const Icon(YaruIcons.warning), + trailing: BusyMaxPushButton.standard( + key: ValueKey( + registrationRecovery && domain == null + ? 'replace-registration-recovery-${account.id}' + : domain == null + ? 'account-recovery-${account.id}' + : 'account-domain-recovery-$domain-${account.id}', ), - ), - if (account.provider == BusyProvider.appleICloud || - account.provider == BusyProvider.nextcloud) - BusyMaxActionRow( - title: account.lastSuccessfulSyncAtUtc == null - ? l10n.davNeverSynced - : l10n.davLastSuccessfulSync( - _formatDavDateTime( - context, - account.lastSuccessfulSyncAtUtc!, - ), - ), - leading: const Icon(YaruIcons.sync), - ), - if (account.hasConnectionIssue) - BusyMaxActionRow( - title: account.needsReconnect - ? (account.provider == BusyProvider.google - ? l10n.reconnectGoogleAccount - : l10n.reconnectAccount) - : _accountConnectionIssueMessage(context, account), - subtitle: _accountConnectionIssueMessage(context, account), - leading: Icon( - YaruIcons.refresh, - color: Theme.of(context).colorScheme.error, + onPressed: + removing || + refreshing || + domain != null && activity.isRunning(account.id, domain) + ? null + : recovery(feedback.recovery, domain: domain), + child: Text( + refreshing ? l10n.traySyncing : recoveryLabel(feedback.recovery), ), - onTap: account.needsReconnect ? onReconnect : null, ), - if (onRefreshCollections != null) + ); + return BusyMaxGroupedList( + filled: true, + children: [ + if (primary != null) + failureRow(primary) + else ...[ BusyMaxActionRow( - title: l10n.refreshCollections, + title: refreshing ? l10n.traySyncing : l10n.refreshNow, leading: const Icon(YaruIcons.sync), - onTap: removing ? null : onRefreshCollections, + onTap: removing || refreshing ? null : onRefreshCollections, ), - BusyMaxActionRow( - title: removing ? l10n.removingAccount : l10n.removeAccount, - subtitle: l10n.removeAccountDescription, - leading: Icon( - YaruIcons.trash, - color: Theme.of(context).colorScheme.error, - ), - enabled: !removing, - onTap: removing ? null : onRemoveAccount, - ), + if (account.provider == BusyProvider.google || + account.provider == BusyProvider.microsoft) + for (final domain in [ + if (account.calendarsEnabled) 'calendar', + if (account.isTaskCapable) 'tasks', + ]) + if (activity.failure(account.id, domain) case final error?) + failureRow( + AccountSettingsFeedback.fromError(l10n, error), + domain: domain, + ), + ], ], ); } } -class _DavCollectionsCard extends StatelessWidget { +String _collectionEmptyMessage( + BuildContext context, + WidgetRef ref, { + required AccountEntity account, + required bool loading, + required bool failed, +}) { + final activity = ref.watch(accountSyncActivityProvider); + final dav = + account.provider == BusyProvider.nextcloud || + account.provider == BusyProvider.appleICloud; + if (failed) return context.l10n.collectionsLoadFailed; + if (loading || activity.isRunning(account.id, dav ? 'dav' : 'calendar')) { + return context.l10n.collectionsLoading; + } + if (account.hasConnectionIssue) { + return _accountConnectionIssueMessage(context, account); + } + final error = activity.failure(account.id, dav ? 'dav' : 'calendar'); + if (error != null) { + return AccountSettingsFeedback.fromError(context.l10n, error).message; + } + final discovery = dav + ? ref.watch(davAccountDiscoveryProvider(account.id)) + : null; + if (discovery?.hasError == true || + discovery?.valueOrNull?.lastDiscoveryErrorCode != null) { + return context.l10n.collectionsLoadFailed; + } + if (discovery?.isLoading == true) return context.l10n.collectionsLoading; + final discovered = dav + ? discovery?.valueOrNull != null + : ref + .watch(accountDomainSyncTimesProvider(account.id)) + .valueOrNull?['calendar'] != + null; + return !discovered + ? context.l10n.collectionsNotDiscovered + : context.l10n.collectionsEmpty; +} + +class _DavCollectionsCard extends ConsumerWidget { const _DavCollectionsCard({ + required this.account, required this.collections, required this.onEventsSelected, required this.onTasksSelected, }); - + final AccountEntity account; final List collections; - final void Function(DavCollectionSettingsEntity collection, bool selected) - onEventsSelected; - final void Function(DavCollectionSettingsEntity collection, bool selected) - onTasksSelected; - + final void Function(DavCollectionSettingsEntity, bool) onEventsSelected, + onTasksSelected; @override - Widget build(BuildContext context) { - if (collections.isEmpty) { - return const SizedBox.shrink(); - } - - final l10n = context.l10n; + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(davCollectionsStreamProvider); + final feedback = _primaryAccountFeedback(context, ref, account); return BusyMaxGroupedList( - title: l10n.collectionSettings, + title: context.l10n.collectionSettings, titleStyle: _settingsAccountSubsectionStyle(context), filled: true, children: [ - for (final collection in collections) ...[ + if (collections.isEmpty) + BusyMaxActionRow( + title: feedback != null + ? context.l10n.collectionsUnavailable + : _collectionEmptyMessage( + context, + ref, + account: account, + loading: state.isLoading, + failed: state.hasError, + ), + leading: const Icon(YaruIcons.calendar), + ), + if (collections.isNotEmpty && state.hasError) + BusyMaxActionRow(title: context.l10n.collectionsLoadFailed), + if (collections.isNotEmpty && state.isLoading) + BusyMaxActionRow(title: context.l10n.collectionsLoading), + for (final collection in collections) _DavCollectionItem( key: ValueKey('dav-collection-${collection.id}'), collection: collection, + sharedFailure: feedback, onEventsSelected: onEventsSelected, onTasksSelected: onTasksSelected, ), - if (collection.provider == BusyProvider.nextcloud) - BusyMaxActionRow( - key: ValueKey('dav-collection-settings-${collection.id}'), - title: l10n.nextcloudCollectionSettings, - leading: const Icon(Icons.settings_outlined), - onTap: () => showLinuxNextcloudCollectionDialog( - context, - accountId: collection.accountId, - collectionId: collection.id, - ), - ), - ], ], ); } } -class _DavCollectionItem extends StatelessWidget { +class _DavCollectionItem extends StatefulWidget { const _DavCollectionItem({ super.key, required this.collection, required this.onEventsSelected, required this.onTasksSelected, + this.sharedFailure, }); - final DavCollectionSettingsEntity collection; - final void Function(DavCollectionSettingsEntity collection, bool selected) - onEventsSelected; - final void Function(DavCollectionSettingsEntity collection, bool selected) - onTasksSelected; + final AccountSettingsFeedback? sharedFailure; + final void Function(DavCollectionSettingsEntity, bool) onEventsSelected, + onTasksSelected; + @override + State<_DavCollectionItem> createState() => _DavCollectionItemState(); +} +class _DavCollectionItemState extends State<_DavCollectionItem> { + bool _expanded = false; @override Widget build(BuildContext context) { - final supportsEvents = collection.supportsEvents; - final supportsTasks = collection.supportsTasks; - final details = _davCollectionDetails(context, collection); - final indicator = _DavCollectionIndicator(color: collection.color); - - if (supportsEvents && !supportsTasks) { - return BusyMaxSwitchRow( - key: ValueKey('dav-events-toggle-${collection.id}'), - title: collection.name, - subtitle: details, - value: collection.eventsSelected, - onChanged: (selected) => onEventsSelected(collection, selected), - leading: indicator, - ); - } - - if (supportsTasks && !supportsEvents) { - return BusyMaxSwitchRow( - key: ValueKey('dav-tasks-toggle-${collection.id}'), - title: collection.name, - subtitle: details, - value: collection.tasksSelected, - onChanged: (selected) => onTasksSelected(collection, selected), - leading: indicator, + final collection = widget.collection; + final l10n = context.l10n; + final error = collection.syncErrorCode == null + ? null + : AccountSettingsFeedback.fromDavCode(l10n, collection.syncErrorCode!); + final subtitle = [ + _davCollectionDetails(context, collection), + if (error != null && error.kind != widget.sharedFailure?.kind) + error.message, + ].join(' · '); + final settings = collection.provider != BusyProvider.nextcloud + ? null + : YaruIconButton( + key: ValueKey('dav-collection-settings-${collection.id}'), + tooltip: '${l10n.nextcloudCollectionSettings} · ${collection.name}', + icon: const Icon(YaruIcons.settings), + onPressed: () => showLinuxNextcloudCollectionDialog( + context, + accountId: collection.accountId, + collectionId: collection.id, + ), + ); + if (!(collection.supportsEvents && collection.supportsTasks)) { + return Row( + children: [ + Expanded( + child: BusyMaxSwitchRow( + key: ValueKey( + '${collection.supportsEvents ? 'dav-events-toggle' : 'dav-tasks-toggle'}-${collection.id}', + ), + title: collection.name, + subtitle: subtitle, + leading: _DavCollectionIndicator(color: collection.color), + value: collection.supportsEvents + ? collection.eventsSelected + : collection.tasksSelected, + onChanged: (selected) => collection.supportsEvents + ? widget.onEventsSelected(collection, selected) + : widget.onTasksSelected(collection, selected), + ), + ), + if (settings != null) + Padding( + padding: const EdgeInsetsDirectional.only(end: BusyMaxSpacing.md), + child: settings, + ), + ], ); } - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BusyMaxActionRow( + return Semantics( + expanded: _expanded, + child: YaruExpandable( + key: ValueKey('dav-collection-expander-${collection.id}'), + isExpanded: _expanded, + onChange: (expanded) => setState(() => _expanded = expanded), + expandIconSemanticLabel: collection.name, + header: BusyMaxActionRow( title: collection.name, - subtitle: details, - leading: indicator, + titleWidget: Text(collection.name), + subtitle: subtitle, + subtitleWidget: Text(subtitle), + leading: _DavCollectionIndicator(color: collection.color), + trailing: settings, ), - Padding( - padding: const EdgeInsetsDirectional.only(start: BusyMaxSpacing.xxl), - child: DecoratedBox( - decoration: BoxDecoration( - border: BorderDirectional( - start: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), + child: Column( + children: [ + BusyMaxSwitchRow( + key: ValueKey('dav-events-toggle-${collection.id}'), + title: l10n.calendarContent, + value: collection.eventsSelected, + onChanged: (selected) => + widget.onEventsSelected(collection, selected), + leading: const Icon(YaruIcons.calendar), ), - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: BusyMaxSpacing.xs, - ), - child: Column( - children: [ - BusyMaxSwitchRow( - key: ValueKey('dav-events-toggle-${collection.id}'), - title: context.l10n.calendarContent, - value: collection.eventsSelected, - onChanged: (selected) => - onEventsSelected(collection, selected), - leading: const Icon(YaruIcons.calendar), - ), - Divider( - height: 1, - thickness: 1, - color: Theme.of(context).colorScheme.outlineVariant, - ), - BusyMaxSwitchRow( - key: ValueKey('dav-tasks-toggle-${collection.id}'), - title: context.l10n.taskContent, - value: collection.tasksSelected, - onChanged: (selected) => - onTasksSelected(collection, selected), - leading: const Icon(YaruIcons.checkmark), - ), - ], - ), + BusyMaxSwitchRow( + key: ValueKey('dav-tasks-toggle-${collection.id}'), + title: l10n.taskContent, + value: collection.tasksSelected, + onChanged: (selected) => + widget.onTasksSelected(collection, selected), + leading: const Icon(YaruIcons.checkmark), ), - ), + ], ), - ], + ), ); } } @@ -3186,21 +4519,21 @@ class _DavConflictsCard extends StatelessWidget { if (conflict.canKeepServer) BusyMaxActionRow( title: l10n.keepServerVersion, - leading: const Icon(Icons.cloud_download_outlined), + leading: const Icon(YaruIcons.download), onTap: () => onResolve(conflict, DavConflictResolution.keepServer), ), if (conflict.canReapplyLocal) BusyMaxActionRow( title: l10n.reapplyLocalChange, - leading: const Icon(Icons.cloud_upload_outlined), + leading: const Icon(YaruIcons.sync), onTap: () => onResolve(conflict, DavConflictResolution.reapplyLocal), ), if (conflict.canDuplicate) BusyMaxActionRow( title: l10n.duplicateLocalItem, - leading: const Icon(Icons.copy_outlined), + leading: const Icon(YaruIcons.copy), onTap: () => onResolve(conflict, DavConflictResolution.duplicateLocal), ), @@ -3228,7 +4561,10 @@ Future _afterAccountRemoved( return; } - ref.read(selectedAccountIdProvider.notifier).state = remaining.first.id; + final selected = ref.read(selectedAccountIdProvider); + if (selected == removedAccountId || !remaining.any((a) => a.id == selected)) { + ref.read(selectedAccountIdProvider.notifier).state = remaining.first.id; + } await ref.read(authSessionControllerProvider.notifier).load(); } @@ -3243,6 +4579,12 @@ List _selectedAccountFirst( } String _accountConnectionErrorMessage(BuildContext context, Object error) { + if (error is DavException && error.code == 'DavAccountAlreadyConnected') { + return context.l10n.accountAlreadyConnected; + } + if (error is DavException) { + return AccountSettingsFeedback.fromError(context.l10n, error).message; + } if (error is NetworkUnavailableException) { return context.l10n.networkOfflineTryAgain; } @@ -3278,6 +4620,14 @@ String _notificationDetailLabel( String _accountIdentityLabel(BuildContext context, AccountEntity account) { final name = account.displayName?.trim(); final address = account.email?.trim(); + if (account.provider == BusyProvider.nextcloud) { + return [ + if (name != null && name.isNotEmpty && name != account.providerAccountId) + name, + account.providerAccountId, + account.authority, + ].join(' · '); + } if (name != null && name.isNotEmpty) { if (address != null && address.isNotEmpty && address != name) { return '$name · $address'; @@ -3355,9 +4705,6 @@ String _davCollectionDetails( if (collection.shared) { details.add(l10n.sharedCollection); } - if (collection.syncErrorCode case final errorCode?) { - details.add(l10n.collectionSyncError(errorCode)); - } return details.join(' · '); } @@ -3386,51 +4733,59 @@ class _RegistrationAccountCard extends ConsumerWidget { required this.account, required this.onMigrate, required this.onReconnect, + this.additionalRows = const [], }); final AccountEntity account; final VoidCallback onMigrate; - final VoidCallback onReconnect; + final VoidCallback? onReconnect; + final List additionalRows; @override Widget build(BuildContext context, WidgetRef ref) { - final summary = ref - .watch(registrationSummariesProvider) - .valueOrNull?[account.id]; + final summaries = ref.watch(registrationSummariesProvider); + final summary = summaries.valueOrNull?[account.id]; final l10n = context.l10n; - return BusyMaxGroupedList( - filled: true, - children: [ - BusyMaxActionRow( - title: summary == null - ? l10n.registrationUnresolved - : l10n.registrationDescription(summary), - subtitle: summary?.showRetirementNotice == true - ? l10n.registrationRetirementNotice( - account.provider.displayName, - account.provider == BusyProvider.google - ? l10n.registrationGoogleProject - : l10n.registrationMicrosoftApp, - ) - : null, - ), - BusyMaxActionRow( - title: summary?.showRetirementNotice == true - ? l10n.registrationMigrate - : l10n.registrationReplace, - onTap: onMigrate, - ), - BusyMaxActionRow( - key: ValueKey('reconnect-account-${account.id}'), - title: - account.needsReconnect || - summary == null || - summary.origin == RegistrationOrigin.retiringShared - ? (account.provider == BusyProvider.google - ? l10n.reconnectGoogleAccount - : l10n.reconnectAccount) - : l10n.connectAccountAction, - onTap: onReconnect, - ), - ], + return YaruExpandable( + key: ValueKey('account-advanced-${account.id}'), + header: Text( + l10n.advancedSection, + style: _settingsSectionHeaderStyle(context), + ), + expandIconSemanticLabel: l10n.advancedSection, + child: BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + key: ValueKey('reconnect-account-${account.id}'), + title: l10n.reconnectAccount, + leading: const Icon(YaruIcons.refresh), + onTap: onReconnect, + ), + BusyMaxActionRow( + title: summaries.isLoading + ? l10n.accountLoadingSignInSettings + : summaries.hasError + ? l10n.oauthSecureStorageUnavailable + : summary == null + ? l10n.registrationUnresolved + : l10n.registrationDescription(summary), + subtitle: summary?.showRetirementNotice == true + ? l10n.registrationRetirementNotice( + account.provider.displayName, + account.provider == BusyProvider.google + ? l10n.registrationGoogleProject + : l10n.registrationMicrosoftApp, + ) + : null, + ), + BusyMaxActionRow( + title: summary?.showRetirementNotice == true + ? l10n.registrationMigrate + : l10n.registrationReplace, + onTap: onMigrate, + ), + ...additionalRows, + ], + ), ); } } diff --git a/lib/src/features/sync/account_sync_activity.dart b/lib/src/features/sync/account_sync_activity.dart new file mode 100644 index 00000000..90e8442b --- /dev/null +++ b/lib/src/features/sync/account_sync_activity.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; + +/// Current-process outcomes are separate from durable successful pull times. +/// A deferred pull never manufactures a new successful synchronization date. +final class AccountSyncActivity extends ChangeNotifier { + final _running = <(String, String), int>{}; + final _failures = <(String, String), Object>{}; + bool _disposed = false; + + bool isRunning(String id, String domain) => (_running[(id, domain)] ?? 0) > 0; + Object? failure(String id, String domain) => _failures[(id, domain)]; + + Future run( + String id, + String domain, + Future Function() work, { + bool Function()? clearFailureOnSuccess, + }) async { + final key = (id, domain); + _running.update(key, (count) => count + 1, ifAbsent: () => 1); + _notify(); + try { + final result = await work(); + // Dispatching queued writes alone does not confirm that a failed pull + // recovered. The caller knows whether a fresh pull actually completed. + if (clearFailureOnSuccess?.call() ?? true) { + _failures.remove(key); + } + return result; + } on Object catch (error) { + _failures[key] = error; + rethrow; + } finally { + final count = (_running[key] ?? 1) - 1; + if (count == 0) { + _running.remove(key); + } else { + _running[key] = count; + } + _notify(); + } + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 65ba601a..ef7eefd8 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -29,6 +29,7 @@ import 'package:busymax/src/providers/busy_provider.dart'; import '../calendar/data/calendar_repository.dart'; import '../recurrence/domain/event_recurrence_codec.dart'; import '../recurrence/domain/recurrence_rule.dart'; +import '../recurrence/domain/series_timing_change.dart'; import 'pending_ops_replay_coordinator.dart'; import 'domain_sync_schedule.dart'; import 'collection_id_replacement.dart'; @@ -862,21 +863,43 @@ class CalendarPendingOpsReplayer { calendarEventGoogleStatusTypeContextKey: type, }; } - final event = await _client.updateEvent( - calendarId: providerCalendarId, - eventId: providerEventId, - mutation: _eventMutation( - mutationRequest, - fallbackTimeZone: await _fallbackTimeZone(op, local: local), - ), - guestUpdatePolicy: _guestUpdatePolicy(request), - ifMatch: _client.provider == BusyProvider.google - ? checkedVersion?.etagOrChangeKey - : null, - ); + if (request[calendarEventSeriesTimingKey] is Map && + recurringScope == 'entireSeries') { + final master = + checkedVersion ?? + await _client.getEvent( + calendarId: providerCalendarId, + eventId: providerEventId, + ); + await _prepareSeriesExceptions(op, request, master); + } + final appliedMaster = request[_seriesAppliedMasterKey]; + final event = appliedMaster is Map && recurringScope == 'entireSeries' + ? _seriesDto(providerCalendarId, appliedMaster.cast()) + : await _client.updateEvent( + calendarId: providerCalendarId, + eventId: providerEventId, + mutation: _eventMutation( + mutationRequest, + fallbackTimeZone: await _fallbackTimeZone(op, local: local), + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: _client.provider == BusyProvider.google + ? checkedVersion?.etagOrChangeKey + : null, + ); if (recurringScope == 'entireSeries') { - await _database.transaction(() => _rebaseDependentEventEdits(op, event)); - await _repository.upsertEvent(accountId: _accountId, event: event); + if (request[calendarEventSeriesTimingKey] is Map) { + request[_seriesAppliedMasterKey] = event.rawJson; + await _saveSeriesRequest(op, request); + await _restoreSeriesExceptions(op, request, event); + } + final dependent = await _database.transaction( + () => _rebaseDependentEventEdits(op, event), + ); + if (!dependent) { + await _repository.upsertEvent(accountId: _accountId, event: event); + } await _markRecurringRowsSynced(op, local); return; } @@ -889,6 +912,718 @@ class CalendarPendingOpsReplayer { }); } + CalendarEventDto _seriesDto(String calendarId, Map raw) => + _client.provider == BusyProvider.google + ? googleCalendarEventFromJson(calendarId, raw) + : microsoftCalendarEventFromJson(calendarId, raw); + + Future _saveSeriesRequest( + PendingOp op, + Map request, + ) async { + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(op.id))).write( + PendingOpsCompanion( + requestJson: Value(jsonEncode(request)), + updatedAtUtc: Value(_nowUtc().toIso8601String()), + ), + ); + } + + Future _storeSeriesMaster( + PendingOp operation, + CalendarEventDto master, + ) async { + final id = CalendarRepository.eventId( + accountId: _accountId, + provider: master.provider, + providerCalendarId: master.providerCalendarId, + providerEventId: master.providerEventId, + ); + await _database.transaction(() async { + final local = await (_database.select( + _database.calendarEvents, + )..where((row) => row.id.equals(id))).getSingleOrNull(); + final pending = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(_accountId) & + row.providerCalendarId.equals(master.providerCalendarId) & + row.entityType.equals('event') & + row.id.equals(operation.id).not(), + )) + .get(); + if (local != null && + (local.syncStatus != 'synced' || + pending.any( + (edit) => + _request(edit)[calendarEventTargetProviderIdKey] == + master.providerEventId, + ))) { + return; + } + await _repository.upsertEvent(accountId: _accountId, event: master); + }); + } + + Future _prepareSeriesExceptions( + PendingOp op, + Map request, + CalendarEventDto master, { + RecurrenceRule? replacementRule, + DateTime? replacementStart, + }) async { + if (request[_seriesExceptionsKey] is List) return; + final client = _client; + if (client is! SeriesExceptionClient) { + await _blockOp( + op, + 'series_exception_preservation_required', + 'The provider cannot read complete exception state for this series edit.', + ); + throw const _PendingOpBlocked(); + } + final plan = SeriesTimingChange.fromJson( + (request[calendarEventSeriesTimingKey] as Map).cast(), + ); + bool includes(DateTime value) => plan.containsNewOccurrence( + value, + recurrence: replacementRule, + startingAt: replacementStart, + ); + final following = + request[calendarEventRecurringScopeKey] == 'thisAndFuture'; + final boundary = providerDateTimeAsCivilTime( + request[calendarEventOriginalStartKey]?.toString(), + master.startTimeZone, + ); + bool inScope(DateTime value) => + !following || boundary == null || !value.isBefore(boundary); + late final List exceptions; + try { + exceptions = await (client as SeriesExceptionClient).readSeriesExceptions( + calendarId: op.providerCalendarId!, + master: master, + ); + } on SeriesExceptionPreservationUnsupported catch (error) { + await _blockOp( + op, + 'series_exception_preservation_required', + error.message, + ); + throw const _PendingOpBlocked(); + } + final snapshots = >[]; + final mappedKeys = {}; + for (final exception in exceptions) { + final original = providerDateTimeAsCivilTime( + exception.providerOriginalStartKey, + master.startTimeZone, + ); + if (original == null || + exception.providerRecurringEventId != master.providerEventId) { + await _blockConflict( + op, + 'An exception has no reliable recurrence identity. The series was not changed.', + ); + } + if (!inScope(original!)) continue; + if ((exception.isDeleted || exception.isCancelled) && + !plan.containsOriginalOccurrence(original)) { + final recurrence = master.recurrenceJson; + if (recurrence is List && + recurrence.any( + (line) => line.toString().toUpperCase().startsWith('RDATE'), + )) { + await _blockConflict( + op, + 'A cancellation outside the regular pattern needs an explicit recurrence decision. The series was not changed.', + ); + } + // Retain historical cancellation markers whose positions are no + // longer generated by the authoritative rule. They do not cancel a + // different occurrence in the new pattern. + continue; + } + final mapped = plan.mapOccurrence(original); + if (mapped == null || !includes(mapped) || !mappedKeys.add(mapped)) { + await _blockConflict( + op, + 'An exception cannot be associated with the revised pattern safely. The series was not changed.', + ); + } + late final CalendarEventDto desired; + try { + desired = _seriesExceptionWithResolvedTiming( + exception, + master, + plan, + request, + ); + } on SeriesExceptionPreservationUnsupported catch (error) { + await _blockOp( + op, + 'series_exception_preservation_required', + error.message, + ); + throw const _PendingOpBlocked(); + } + if (_client.provider == BusyProvider.microsoft && + !exception.isDeleted && + !exception.isCancelled) { + final instant = providerDateTimeAsUtcInstant( + desired.startDateTime, + desired.startTimeZone, + ); + final actual = providerDateTimeAsCivilTime( + desired.allDay ? desired.startDate : instant?.toIso8601String(), + master.startTimeZone, + ); + final adjacent = plan.adjacentNewOccurrences(mapped!); + DateTime day(DateTime value) => + DateTime.utc(value.year, value.month, value.day); + if (actual == null || + adjacent.previous != null && + !day(actual).isAfter(day(adjacent.previous!)) || + adjacent.next != null && + !day(actual).isBefore(day(adjacent.next!))) { + await _blockOp( + op, + 'series_exception_preservation_required', + 'Microsoft forbids moving an exception onto or beyond an adjacent occurrence’s day. ' + 'This pattern would cross that boundary while keeping the exception’s schedule. The series was not changed.', + ); + throw const _PendingOpBlocked(); + } + } + snapshots.add({ + 'original': exception.providerOriginalStartKey, + 'mapped': mapped!.toIso8601String(), + 'raw': exception.rawJson, + 'desired': desired.rawJson, + }); + } + final cached = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(_accountId) & + row.provider.equals(_client.provider.storageValue) & + row.providerCalendarId.equals(op.providerCalendarId!) & + row.providerRecurringEventId.equals(master.providerEventId), + )) + .get(); + final positions = >[]; + for (final row in cached) { + final original = providerDateTimeAsCivilTime( + row.providerOriginalStartKey, + master.startTimeZone, + ); + if (original == null || !inScope(original)) continue; + if (row.isDeleted && !plan.containsOriginalOccurrence(original)) continue; + final projected = _jsonObject( + row.rawJson ?? '{}', + )['_seriesTimingProjection']; + final mapped = + projected is Map && + projected['signature'] == plan.signature && + row.providerEventId.startsWith('local:series:') + ? DateTime.parse(projected['mapped'] as String) + : plan.mapOccurrence(original); + final removed = mapped == null + ? plan.removesOrdinaryOccurrence( + original, + recurrence: replacementRule, + startingAt: replacementStart, + ) + : !includes(mapped); + if (mapped == null && !removed) { + await _blockConflict( + op, + 'A cached occurrence cannot be associated with the revised pattern safely. The series was not changed.', + ); + } + if (removed) { + final dependent = + await (_database.select(_database.pendingOps)..where( + (operation) => + operation.accountId.equals(_accountId) & + operation.eventId.equals(row.id) & + operation.id.equals(op.id).not(), + )) + .get(); + if (dependent.isNotEmpty) { + await _blockConflict( + op, + 'An edited occurrence would fall outside the revised series ending condition. The series was not changed.', + ); + } + } + positions.add({ + 'id': row.id, + 'original': row.providerOriginalStartKey, + 'mapped': mapped?.toIso8601String(), + 'removed': removed, + }); + } + request[_seriesExceptionsKey] = snapshots; + request[_seriesLocalOccurrencesKey] = positions; + request[calendarEventSeriesCheckedMasterKey] = master.rawJson; + await _saveSeriesRequest(op, request); + } + + Future _restoreSeriesExceptions( + PendingOp op, + Map request, + CalendarEventDto master, + ) async { + final client = _client as SeriesExceptionClient; + final plan = SeriesTimingChange.fromJson( + (request[calendarEventSeriesTimingKey] as Map).cast(), + ); + final entries = request[_seriesExceptionsKey] as List; + for (var index = 0; index < entries.length; index++) { + final entry = Map.from(entries[index] as Map); + final exception = _seriesDto( + op.providerCalendarId!, + Map.from(entry['raw'] as Map), + ); + // Staging is durable before the first exception write. An interrupted + // operation can also reach this step with only its original snapshot. + if (entry['desired'] is! Map) { + final checkedMaster = _seriesDto( + op.providerCalendarId!, + Map.from( + request[calendarEventSeriesCheckedMasterKey] as Map, + ), + ); + entry['desired'] = _seriesExceptionWithResolvedTiming( + exception, + checkedMaster, + plan, + request, + ).rawJson; + entries[index] = entry; + await _saveSeriesRequest(op, request); + } + final desired = _seriesDto( + op.providerCalendarId!, + Map.from(entry['desired'] as Map), + ); + if (entry['restored'] is! Map) { + final instance = await client.findSeriesInstance( + calendarId: op.providerCalendarId!, + recurringEventId: master.providerEventId, + originalStart: DateTime.parse(entry['mapped'] as String), + allDay: plan.allDay, + timeZone: plan.timeZone, + ); + final checked = entry['checked']; + final cancelled = exception.isDeleted || exception.isCancelled; + final desiredMatches = + cancelled && (instance.isDeleted || instance.isCancelled) || + _deepEquals( + _exceptionWritableState(instance.rawJson), + _exceptionWritableState(desired.rawJson), + ); + if (!cancelled && (instance.isDeleted || instance.isCancelled)) { + await _blockConflict( + op, + 'The revised occurrence was cancelled while its exception was being preserved.', + ); + } + if (checked is Map && + !desiredMatches && + !_deepEquals( + _exceptionWritableState(instance.rawJson), + _exceptionWritableState(checked.cast()), + )) { + await _blockConflict( + op, + 'The revised occurrence changed while its exception was being preserved.', + ); + } + if (checked is! Map) { + entry['checked'] = instance.rawJson; + entries[index] = entry; + await _saveSeriesRequest(op, request); + } + late final CalendarEventDto restored; + try { + restored = desiredMatches + ? instance + : await client.restoreSeriesException( + calendarId: op.providerCalendarId!, + instance: instance, + exception: desired, + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + } on SeriesExceptionPreservationUnsupported catch (error) { + await _blockOp( + op, + 'series_exception_preservation_required', + error.message, + ); + throw const _PendingOpBlocked(); + } + if (restored.providerEventId != instance.providerEventId || + restored.providerRecurringEventId != master.providerEventId || + restored.providerOriginalStartKey != + instance.providerOriginalStartKey || + !_sameExceptionSchedule(restored, desired)) { + await _blockConflict( + op, + 'The provider did not preserve the exception at its revised recurrence position.', + ); + } + entry['restored'] = restored.rawJson; + entries[index] = entry; + await _saveSeriesRequest(op, request); + } + final restored = _seriesDto( + op.providerCalendarId!, + Map.from(entry['restored'] as Map), + ); + if (entry['oldRetired'] != true && + exception.providerEventId != restored.providerEventId) { + CalendarEventDto? old; + try { + old = await _client.getEvent( + calendarId: op.providerCalendarId!, + eventId: exception.providerEventId, + ); + } on GoogleCalendarApiError catch (error) { + if (error.statusCode != 404 && error.statusCode != 410) rethrow; + } on MicrosoftCalendarApiError catch (error) { + if (error.statusCode != 404 && error.statusCode != 410) rethrow; + } + if (old != null && !old.isDeleted && !old.isCancelled) { + if (old.providerRecurringEventId != + exception.providerRecurringEventId || + old.providerOriginalStartKey != + exception.providerOriginalStartKey || + !_deepEquals( + _exceptionWritableState(old.rawJson), + _exceptionWritableState(exception.rawJson), + )) { + await _blockConflict( + op, + 'The original exception changed while its new recurrence position was being preserved.', + ); + } + await _client.deleteEvent( + calendarId: op.providerCalendarId!, + eventId: old.providerEventId, + guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: old.etagOrChangeKey, + ); + } + entry['oldRetired'] = true; + entries[index] = entry; + await _saveSeriesRequest(op, request); + } + } + final positions = request[_seriesLocalOccurrencesKey] as List; + for (var index = 0; index < positions.length; index++) { + final position = Map.from(positions[index] as Map); + if (position['done'] == true) continue; + if (position['removed'] == true) { + await (_database.update(_database.calendarEvents)..where( + (row) => + row.accountId.equals(_accountId) & + row.id.equals(position['id'] as String), + )) + .write( + const CalendarEventsCompanion( + isDeleted: Value(true), + syncStatus: Value('synced'), + ), + ); + position['done'] = true; + positions[index] = position; + await _saveSeriesRequest(op, request); + continue; + } + final preserved = entries.cast().where( + (entry) => entry['original'] == position['original'], + ); + final event = preserved.isNotEmpty + ? _seriesDto( + op.providerCalendarId!, + Map.from(preserved.single['restored'] as Map), + ) + : await client.findSeriesInstance( + calendarId: op.providerCalendarId!, + recurringEventId: master.providerEventId, + originalStart: DateTime.parse(position['mapped'] as String), + allDay: plan.allDay, + timeZone: plan.timeZone, + ); + await _replaceSeriesOccurrence( + op, + position['id'] as String, + event, + request: request, + positions: positions, + index: index, + position: position, + ); + } + } + + Map _exceptionWritableState(Map raw) { + final state = _client.provider == BusyProvider.google + ? (googleEventMutationToJson(CalendarEventMutation(providerRaw: raw)) + ..['status'] = raw['status'] ?? 'confirmed') + : _semanticSnapshot(_client.provider, raw); + if (state['allDay'] == true) return state; + // A checked or acknowledged revision may use different RFC3339 offsets + // or fractional precision. Compare instants while retaining zone identity + // and every other writable field for concurrent-change detection. + for (final field in ['start', 'end']) { + final temporal = _mapValue(raw[field]); + final value = temporal['dateTime']?.toString(); + if (value == null) continue; + final instant = providerDateTimeAsUtcInstant( + value, + temporal['timeZone']?.toString(), + ); + if (instant == null) continue; + state[field] = _client.provider == BusyProvider.google + ? {..._mapValue(state[field]), 'dateTime': instant.toIso8601String()} + : instant.toIso8601String(); + } + return state; + } + + bool _sameExceptionSchedule( + CalendarEventDto restored, + CalendarEventDto original, + ) { + if (original.isDeleted || original.isCancelled) { + return restored.isDeleted || restored.isCancelled; + } + if (restored.allDay != original.allDay) return false; + if (original.allDay) { + return restored.startDate == original.startDate && + restored.endDate == original.endDate; + } + return providerDateTimeAsUtcInstant( + restored.startDateTime, + restored.startTimeZone, + ) == + providerDateTimeAsUtcInstant( + original.startDateTime, + original.startTimeZone, + ) && + providerDateTimeAsUtcInstant( + restored.endDateTime, + restored.endTimeZone, + ) == + providerDateTimeAsUtcInstant( + original.endDateTime, + original.endTimeZone, + ); + } + + CalendarEventDto _seriesExceptionWithResolvedTiming( + CalendarEventDto exception, + CalendarEventDto master, + SeriesTimingChange plan, + Map request, + ) { + if (exception.isDeleted || exception.isCancelled) return exception; + DateTime endpoint(CalendarEventDto event, bool end) { + final value = end + ? (event.allDay ? event.endDate : event.endDateTime) + : (event.allDay ? event.startDate : event.startDateTime); + if (event.allDay) return _naiveWallTime(DateTime.parse(value!)); + final instant = providerDateTimeAsUtcInstant( + value, + end ? event.endTimeZone : event.startTimeZone, + )!; + return _naiveWallTime( + providerInstantInTimeZone(instant, master.startTimeZone), + ); + } + + final start = endpoint(exception, false); + final end = endpoint(exception, true); + final baseline = request[calendarEventTimingBaselineKey] as Map; + final beforeEnd = providerDateTimeAsCivilTime( + baseline['end']?.toString(), + baseline['endTimeZone']?.toString(), + )!; + final desiredEnd = request.containsKey('end') + ? providerDateTimeAsCivilTime( + request['end']?.toString(), + request['endTimeZone']?.toString(), + )! + : beforeEnd; + final resolved = plan.occurrenceTiming( + originalStart: providerDateTimeAsCivilTime( + exception.providerOriginalStartKey, + master.startTimeZone, + )!, + start: start, + end: end, + baselineStart: start, + baselineEnd: end, + masterDuration: endpoint( + master, + true, + ).difference(endpoint(master, false)), + endDelta: desiredEnd.difference(beforeEnd), + startChanged: request.containsKey('start'), + endChanged: request.containsKey('end'), + ); + if (resolved == null || resolved.end.isBefore(resolved.start)) { + throw const SeriesExceptionPreservationUnsupported( + 'An exception has no valid interval in the revised series. The series was not changed.', + ); + } + if (resolved.preserved) return exception; + Map temporal(DateTime wall, String? zone) { + if (plan.allDay && _client.provider == BusyProvider.google) { + return {'date': _isoDate(wall)}; + } + final instant = providerWallTimeToInstant(wall, plan.timeZone); + return { + 'dateTime': _client.provider == BusyProvider.google + ? instant.toUtc().toIso8601String() + : providerWallTimeIso8601String( + providerInstantInTimeZone(instant, zone), + ), + if (zone != null) 'timeZone': zone, + }; + } + + return _seriesDto(master.providerCalendarId, { + ...exception.rawJson, + if (_client.provider == BusyProvider.microsoft) 'isAllDay': plan.allDay, + 'start': temporal( + resolved.start, + request['startTimeZone']?.toString() ?? plan.timeZone, + ), + 'end': temporal( + resolved.end, + request['endTimeZone']?.toString() ?? plan.timeZone, + ), + }); + } + + Future _replaceSeriesOccurrence( + PendingOp completed, + String oldId, + CalendarEventDto event, { + required Map request, + required List positions, + required int index, + required Map position, + }) async { + await _database.transaction(() async { + final old = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.id.equals(oldId) & + row.accountId.equals(_accountId) & + row.providerCalendarId.equals( + completed.providerCalendarId!, + ), + )) + .getSingleOrNull(); + final nextId = CalendarRepository.eventId( + accountId: _accountId, + provider: event.provider, + providerCalendarId: event.providerCalendarId, + providerEventId: event.providerEventId, + providerOriginalStartKey: event.providerOriginalStartKey, + ); + final dependent = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(_accountId) & + row.eventId.equals(oldId) & + row.id.equals(completed.id).not(), + )) + .get(); + final laterSeries = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(_accountId) & + row.providerCalendarId.equals( + completed.providerCalendarId!, + ) & + row.entityType.equals('event') & + row.id.equals(completed.id).not(), + )) + .get(); + final seriesProjectionPending = + old != null && + laterSeries.any( + (operation) => + _request(operation)[calendarEventTargetProviderIdKey] == + old.providerRecurringEventId, + ); + await _repository.upsertEvent(accountId: _accountId, event: event); + if (old != null) { + if (dependent.isNotEmpty || seriesProjectionPending) { + await (_database.update(_database.calendarEvents) + ..where((row) => row.id.equals(nextId))) + .write(_pendingLocalEventProjection(old)); + } + await LocationResolutionRepository(_database).transfer( + LocationItemIdentity( + kind: LocationItemKind.event, + accountId: _accountId, + sourceId: old.calendarSourceId, + itemId: oldId, + ), + LocationItemIdentity( + kind: LocationItemKind.event, + accountId: _accountId, + sourceId: old.calendarSourceId, + itemId: nextId, + ), + ); + } + for (final pending in dependent) { + final next = _request(pending); + if (next.containsKey(calendarEventOriginalStartKey)) { + next[calendarEventOriginalStartKey] = event.providerOriginalStartKey; + } + if (next.containsKey(calendarEventTargetProviderIdKey)) { + next[calendarEventTargetProviderIdKey] = + event.providerRecurringEventId; + } + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(pending.id))).write( + PendingOpsCompanion( + eventId: Value(nextId), + requestJson: Value(jsonEncode(next)), + ), + ); + } + await _rebaseDependentEventEdits(completed, event); + await (_database.update(_database.pendingOps)..where( + (row) => + row.id.equals(completed.id) & + row.accountId.equals(_accountId) & + row.eventId.equals(oldId), + )) + .write(PendingOpsCompanion(eventId: Value(nextId))); + if (oldId != nextId) { + await (_database.delete( + _database.calendarEvents, + )..where((row) => row.id.equals(oldId))).go(); + } + position['done'] = true; + positions[index] = position; + await _saveSeriesRequest(completed, request); + }); + } + Future _rebaseDependentEventEdits( PendingOp completedOp, CalendarEventDto serverEvent, @@ -1144,16 +1879,33 @@ class CalendarPendingOpsReplayer { originalMaster, provider: _client.provider, ); - await _client.updateEvent( - calendarId: calendarId, - eventId: masterId, - mutation: _eventMutation( - mutationRequest, - fallbackTimeZone: await _fallbackTimeZone(op, local: local), - ), - guestUpdatePolicy: _guestUpdatePolicy(request), - ifMatch: checkedVersion?.etagOrChangeKey, - ); + if (request[calendarEventSeriesTimingKey] is Map) { + // At the first occurrence, following edits update the whole master. + // Persist the exact attempt before the HTTP call so a lost response + // can be recognized without applying the timing difference twice. + request.putIfAbsent(_googleTrimRequestKey, () => mutationRequest); + await _prepareSeriesExceptions(op, request, originalMaster); + } + final applied = request[_seriesAppliedMasterKey]; + final savedMaster = applied is Map + ? _seriesDto(calendarId, applied.cast()) + : await _client.updateEvent( + calendarId: calendarId, + eventId: masterId, + mutation: _eventMutation( + mutationRequest, + fallbackTimeZone: await _fallbackTimeZone(op, local: local), + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: checkedVersion?.etagOrChangeKey, + ); + if (request[calendarEventSeriesTimingKey] is Map) { + request[_seriesAppliedMasterKey] = savedMaster.rawJson; + request[_googleTrimmedMasterKey] = savedMaster.rawJson; + await _saveSeriesRequest(op, request); + await _storeSeriesMaster(op, savedMaster); + await _restoreSeriesExceptions(op, request, savedMaster); + } if (request.containsKey('location')) { await LocationResolutionRepository(_database) .removeGoogleSeriesSourceUnlessLocationMatches( @@ -1167,10 +1919,15 @@ class CalendarPendingOpsReplayer { return; } + final timing = request[calendarEventSeriesTimingKey]; + final recurrenceStart = timing is Map + ? SeriesTimingChange.fromJson(timing.cast()) + .originalMasterStart + : masterStart; final rule = EventRecurrenceCodec.decode( BusyProvider.google, originalMaster.recurrenceJson, - baseDate: masterStart, + baseDate: recurrenceStart, ); if (!rule.isSupported || !rule.repeats) { await _blockOp( @@ -1188,12 +1945,12 @@ class CalendarPendingOpsReplayer { final trimmedRecurrence = EventRecurrenceCodec.encode( BusyProvider.google, trimmedRule, - baseDate: masterStart, + baseDate: recurrenceStart, allDay: originalMaster.allDay, timeZone: originalMaster.startTimeZone, original: originalMaster.recurrenceJson, ); - final followingRule = await _followingGoogleRule( + var followingRule = await _followingGoogleRule( rule, calendarId: calendarId, masterId: masterId, @@ -1216,14 +1973,6 @@ class CalendarPendingOpsReplayer { ); } - await _client.updateEvent( - calendarId: calendarId, - eventId: masterId, - mutation: CalendarEventMutation(recurrence: trimmedRecurrence), - guestUpdatePolicy: _guestUpdatePolicy(request), - ifMatch: checkedVersion?.etagOrChangeKey, - ); - final splitRequest = { ..._semanticSnapshot(BusyProvider.google, originalMaster.rawJson), for (final entry in request.entries) @@ -1244,6 +1993,25 @@ class CalendarPendingOpsReplayer { : local.endTimeZone, }; final splitStart = _requiredDateTime(splitRequest['start'], 'start'); + // A regular series has no status-event discriminator. The semantic + // snapshot's empty status-property map is not a provider mutation. + splitRequest.remove('googleStatusProperties'); + splitRequest.remove('googleStatusEventType'); + final timingPlan = request[calendarEventSeriesTimingKey]; + if (timingPlan is Map) { + final plan = SeriesTimingChange.fromJson( + timingPlan.cast(), + ); + if (followingRule.count != plan.previewRule(following: true).count) { + await _blockConflict( + op, + 'The provider occurrence positions do not agree with the approved following-series count. The series was not changed.', + ); + } + // Only COUNT is reduced by the split. The approved plan already aligned + // UNTIL with the new time on the unchanged ending date. + followingRule = plan.rule.copyWith(count: followingRule.count); + } splitRequest[calendarEventRecurrenceField] = EventRecurrenceCodec.encode( BusyProvider.google, followingRule, @@ -1258,26 +2026,86 @@ class CalendarPendingOpsReplayer { } else { splitRequest.remove('conferenceJson'); } - final providerRaw = {...originalMaster.rawJson}..remove('conferenceData'); - final splitEventId = op.id.replaceAll('-', '').toLowerCase(); - late final CalendarEventDto splitEvent; - try { - splitEvent = await _client.createEvent( + if (request[calendarEventSeriesTimingKey] is Map) { + await _prepareSeriesExceptions( + op, + request, + originalMaster, + replacementRule: followingRule, + replacementStart: splitStart, + ); + } + // The replacement rule, its ending mode and every exception mapping have + // been validated before trimming the source series. + if (request[_googleTrimmedMasterKey] is! Map) { + if (request[calendarEventSeriesTimingKey] is Map) { + request[_googleTrimRequestKey] = { + calendarEventRecurrenceField: trimmedRecurrence, + }; + await _saveSeriesRequest(op, request); + } + final trimmed = await _client.updateEvent( calendarId: calendarId, - mutation: _eventMutation( - splitRequest, - fallbackTimeZone: await _fallbackTimeZone(op, local: local), - providerEventId: splitEventId, - providerRaw: providerRaw, - ), + eventId: masterId, + mutation: CalendarEventMutation(recurrence: trimmedRecurrence), guestUpdatePolicy: _guestUpdatePolicy(request), + ifMatch: checkedVersion?.etagOrChangeKey, ); - } on GoogleCalendarApiError catch (error) { - if (error.statusCode != 409) rethrow; + if (request[calendarEventSeriesTimingKey] is Map) { + request[_googleTrimmedMasterKey] = trimmed.rawJson; + await _saveSeriesRequest(op, request); + await _storeSeriesMaster(op, trimmed); + } + } else if (request[calendarEventSeriesTimingKey] is Map) { + await _storeSeriesMaster(op, currentMaster); + } + + final providerRaw = {...originalMaster.rawJson}..remove('conferenceData'); + final splitEventId = op.id.replaceAll('-', '').toLowerCase(); + late final CalendarEventDto splitEvent; + final savedSplit = request[_seriesAppliedMasterKey]; + if (savedSplit is Map) { + final saved = _seriesDto(calendarId, savedSplit.cast()); splitEvent = await _client.getEvent( calendarId: calendarId, - eventId: splitEventId, + eventId: saved.providerEventId, ); + if (_changedSemanticFields( + {'start': true, 'end': true, calendarEventRecurrenceField: true}, + _semanticSnapshot(_client.provider, saved.rawJson), + _semanticSnapshot(_client.provider, splitEvent.rawJson), + _client.provider, + ).isNotEmpty) { + await _blockConflict( + op, + 'The replacement series changed while its exceptions were being preserved.', + ); + } + } else { + try { + splitEvent = await _client.createEvent( + calendarId: calendarId, + mutation: _eventMutation( + splitRequest, + fallbackTimeZone: await _fallbackTimeZone(op, local: local), + providerEventId: splitEventId, + providerRaw: providerRaw, + ), + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + } on GoogleCalendarApiError catch (error) { + if (error.statusCode != 409) rethrow; + splitEvent = await _client.getEvent( + calendarId: calendarId, + eventId: splitEventId, + ); + } + } + if (request[calendarEventSeriesTimingKey] is Map) { + request[_seriesAppliedMasterKey] = splitEvent.rawJson; + await _saveSeriesRequest(op, request); + await _storeSeriesMaster(op, splitEvent); + await _restoreSeriesExceptions(op, request, splitEvent); } await LocationResolutionRepository(_database).copyGoogleSeriesSource( accountId: _accountId, @@ -1966,16 +2794,89 @@ class CalendarPendingOpsReplayer { Map request, ) async { final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); - if (baselineUpdatedUtc == null) { + if (baselineUpdatedUtc == null && + request[calendarEventSeriesTimingKey] is! Map) { return null; } final current = await _client.getEvent( calendarId: op.providerCalendarId ?? local.providerCalendarId, eventId: await _providerEventId(op, local), ); + final applied = + request[request[calendarEventRecurringScopeKey] == 'thisAndFuture' + ? _googleTrimmedMasterKey + : _seriesAppliedMasterKey]; + if (applied is Map) { + final previous = _semanticSnapshot( + _client.provider, + applied.cast(), + ); + final remote = _semanticSnapshot(_client.provider, current.rawJson); + final changed = _changedSemanticFields( + request, + previous, + remote, + _client.provider, + ); + if (changed.isNotEmpty) { + await _blockConflict( + op, + 'The recurring series changed while its exceptions were being preserved.', + ); + } + return current; + } + if (request[_seriesExceptionsKey] is List) { + final resolved = request[_seriesResolvedRequestKey]; + final trimAttempt = request[_googleTrimRequestKey]; + if (request[calendarEventRecurringScopeKey] == 'entireSeries' && + resolved is Map && + _seriesRequestMatches(current, resolved.cast())) { + request[_seriesAppliedMasterKey] = current.rawJson; + await _saveSeriesRequest(op, request); + return current; + } + if (request[calendarEventRecurringScopeKey] == 'thisAndFuture' && + trimAttempt is Map && + _seriesRequestMatches(current, trimAttempt.cast())) { + final remainingFields = {...request}; + for (final field in trimAttempt.keys) { + remainingFields.remove(field); + } + final changed = _changedSemanticFields( + remainingFields, + _eventBaselineSnapshot( + _client.provider, + op.baselineRawJson ?? local.baselineRawJson ?? '{}', + ), + _semanticSnapshot(_client.provider, current.rawJson), + _client.provider, + ); + if (changed.isNotEmpty) { + await _blockConflict( + op, + 'Remote event changed fields: ${changed.toList()..sort()}', + ); + } + request[_googleTrimmedMasterKey] = current.rawJson; + if (trimAttempt.containsKey('start')) { + request[_seriesAppliedMasterKey] = current.rawJson; + } + await _saveSeriesRequest(op, request); + return current; + } + } + if (baselineUpdatedUtc == null) return current; final currentUpdatedUtc = _parseUtc(current.updatedAtServer); - if (currentUpdatedUtc == null || - !currentUpdatedUtc.isAfter(baselineUpdatedUtc)) { + final mainDataAdvanced = + currentUpdatedUtc != null && + currentUpdatedUtc.isAfter(baselineUpdatedUtc); + // Google reminder changes update the ETag without advancing `updated`. + // Compare the fields being written against the user's baseline before + // using the fetched ETag to protect the checked revision at PATCH time. + if (!mainDataAdvanced && + (_client.provider != BusyProvider.google || + !request.containsKey('remindersJson'))) { return current; } final baseline = _eventBaselineSnapshot( @@ -1984,7 +2885,7 @@ class CalendarPendingOpsReplayer { ); final remote = _semanticSnapshot(_client.provider, current.rawJson); final changed = _changedSemanticFields( - request, + mainDataAdvanced ? request : {'remindersJson': request['remindersJson']}, baseline, remote, _client.provider, @@ -1999,6 +2900,56 @@ class CalendarPendingOpsReplayer { return null; } + bool _seriesRequestMatches( + CalendarEventDto current, + Map request, + ) { + final mutation = _eventMutation( + request, + fallbackTimeZone: current.startTimeZone, + ); + final body = _client.provider == BusyProvider.google + ? googleEventMutationToJson(mutation) + : microsoftEventMutationToJson(mutation); + final expected = _semanticSnapshot(_client.provider, body); + final actual = _semanticSnapshot(_client.provider, current.rawJson); + for (final field in _eventMutationFields(request, _client.provider)) { + Object? left = expected[field]; + Object? right = actual[field]; + if ((field == 'start' || field == 'end') && expected['allDay'] != true) { + left = providerDateTimeAsUtcInstant( + left?.toString(), + expected['${field}TimeZone']?.toString(), + )?.toIso8601String(); + right = providerDateTimeAsUtcInstant( + right?.toString(), + actual['${field}TimeZone']?.toString(), + )?.toIso8601String(); + } + if (field == calendarEventRecurrenceField) { + final before = EventRecurrenceCodec.decode( + _client.provider, + left, + baseDate: _dtoStart(current), + ); + final after = EventRecurrenceCodec.decode( + _client.provider, + right, + baseDate: _dtoStart(current), + ); + if (before.isSupported && + before.repeats && + after.isSupported && + after.repeats) { + left = before.toRrule(); + right = after.toRrule(); + } + } + if (!_deepEquals(left, right)) return false; + } + return true; + } + Future _ensureEventUnchanged( PendingOp op, CalendarEvent local, @@ -2069,6 +3020,8 @@ class CalendarPendingOpsReplayer { calendarEventOriginalStartKey, calendarEventOriginalEndKey, calendarEventTimingBaselineKey, + calendarEventSeriesTimingKey, + calendarEventSeriesCheckedMasterKey, calendarEventDestinationCalendarIdKey, calendarEventDestinationSourceIdKey, calendarEventCopyConfirmationRequiredKey, @@ -2077,6 +3030,11 @@ class CalendarPendingOpsReplayer { _googleSplitMasterRawKey, _googleSplitFollowingCountKey, _seriesResolvedRequestKey, + _seriesExceptionsKey, + _seriesLocalOccurrencesKey, + _seriesAppliedMasterKey, + _googleTrimmedMasterKey, + _googleTrimRequestKey, calendarEventGoogleStatusTypeContextKey, }; final fields = { @@ -2575,6 +3533,11 @@ class _PendingOpBlocked { const _googleSplitMasterRawKey = '_googleSplitMasterRaw'; const _googleSplitFollowingCountKey = '_googleSplitFollowingCount'; const _seriesResolvedRequestKey = '_seriesResolvedRequest'; +const _seriesExceptionsKey = '_seriesExceptions'; +const _seriesLocalOccurrencesKey = '_seriesLocalOccurrences'; +const _seriesAppliedMasterKey = calendarEventSeriesAppliedMasterKey; +const _googleTrimmedMasterKey = '_googleTrimmedMaster'; +const _googleTrimRequestKey = '_googleTrimRequest'; const _eventRequestMetadataFields = { calendarEventClearFieldsKey, @@ -2584,9 +3547,16 @@ const _eventRequestMetadataFields = { calendarEventOriginalStartKey, calendarEventOriginalEndKey, calendarEventTimingBaselineKey, + calendarEventSeriesTimingKey, + calendarEventSeriesCheckedMasterKey, _googleSplitMasterRawKey, _googleSplitFollowingCountKey, _seriesResolvedRequestKey, + _seriesExceptionsKey, + _seriesLocalOccurrencesKey, + _seriesAppliedMasterKey, + _googleTrimmedMasterKey, + _googleTrimRequestKey, }; Map _seriesRequestForMaster( @@ -2597,6 +3567,46 @@ Map _seriesRequestForMaster( final result = {...request}; final baseline = request[calendarEventTimingBaselineKey]; final timing = baseline is Map ? baseline : null; + final planJson = request[calendarEventSeriesTimingKey]; + if (planJson is Map) { + final plan = SeriesTimingChange.fromJson(planJson.cast()); + result['start'] = providerWallTimeIso8601String(plan.masterStart); + final oldStart = providerDateTimeAsCivilTime( + master.allDay ? master.startDate : master.startDateTime, + master.startTimeZone, + )!; + final oldEnd = providerDateTimeAsCivilTime( + master.allDay ? master.endDate : master.endDateTime, + master.endTimeZone, + )!; + final beforeStart = providerDateTimeAsCivilTime( + timing?['start']?.toString(), + timing?['startTimeZone']?.toString(), + )!; + final beforeEnd = providerDateTimeAsCivilTime( + timing?['end']?.toString(), + timing?['endTimeZone']?.toString(), + )!; + final desiredStart = providerDateTimeAsCivilTime( + request['start']?.toString(), + request['startTimeZone']?.toString(), + )!; + final desiredEnd = request.containsKey('end') + ? providerDateTimeAsCivilTime( + request['end']?.toString(), + request['endTimeZone']?.toString(), + )! + : beforeEnd; + result['end'] = providerWallTimeIso8601String( + plan.masterStart.add( + oldEnd.difference(oldStart) + + desiredEnd.difference(beforeEnd) - + desiredStart.difference(beforeStart), + ), + ); + result[calendarEventRecurrenceField] = plan.encode(master.recurrenceJson!); + return result; + } if (request.containsKey('start')) { final timeZone = request['startTimeZone']?.toString(); final masterStart = providerDateTimeAsCivilTime( diff --git a/lib/src/features/sync/calendar_sync_engine.dart b/lib/src/features/sync/calendar_sync_engine.dart index 57113f53..32768570 100644 --- a/lib/src/features/sync/calendar_sync_engine.dart +++ b/lib/src/features/sync/calendar_sync_engine.dart @@ -56,6 +56,7 @@ class CalendarSyncEngine { final CollectionIdReplacement? _onCalendarSourceIdReplaced; final Future Function()? _onNotificationScheduleChanged; final DateTime Function() _nowUtc; + MicrosoftCalendarApiError? _sharedAccessFailure; Future dispatchPendingWrites() async { try { @@ -239,6 +240,7 @@ class CalendarSyncEngine { full: true, ); } + if (_sharedAccessFailure case final failure?) throw failure; } finally { // Earlier pages and pending operations may already have committed. await NotificationScheduleService( @@ -302,6 +304,7 @@ class CalendarSyncEngine { full: requiresSnapshot, ); } + if (_sharedAccessFailure case final failure?) throw failure; } finally { // Earlier pages and pending operations may already have committed. await NotificationScheduleService( @@ -313,6 +316,7 @@ class CalendarSyncEngine { } Future> _refreshCalendarSources() async { + _sharedAccessFailure = null; // listCalendars() returns only after every page has been retrieved, so an // absent cloud calendar can be treated as a provider-side deletion. final calendars = await _client.listCalendars(); @@ -341,6 +345,9 @@ class CalendarSyncEngine { calendars.add(refreshed); } on MicrosoftCalendarApiError catch (error) { if (error.statusCode != 403 && error.statusCode != 404) rethrow; + // Finish other calendars, but do not record this domain as a fully + // successful pull while an opened shared calendar is inaccessible. + _sharedAccessFailure ??= error; // Explicit owner-context denial is not an empty calendar. Keep its // cached source, but remove write actions until access is restored. final oldMetadata = jsonDecode(source.rawJson ?? '{}'); @@ -408,6 +415,16 @@ class CalendarSyncEngine { final expandedRecurringMasterIds = {}; while (true) { for (final event in page.events) { + if (provider == BusyProvider.google && + event.isDeleted && + event.providerOriginalStartKey == null) { + await _repository.markGoogleRemovedEventDeleted( + accountId: _accountId, + providerCalendarId: providerCalendarId, + providerEventId: event.providerEventId, + ); + continue; + } if (provider == BusyProvider.microsoft && event.isDeleted) { // A calendar-view delta removal describes membership in this view, // not necessarily deletion of the event in the mailbox. Resolve it diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index d11f7bbe..58d7c3c1 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; import '../../calendar_providers/cloud_calendar_client.dart'; +import '../../calendar_providers/calendar_mutation.dart'; import '../../dav/mutation/dav_pending_operation_selection.dart'; import '../../dav/mutation/dav_pending_operations.dart'; import '../../db/app_database.dart'; @@ -111,11 +112,21 @@ class PendingOpResolutionService { op.entityType == 'task' || op.entityType == 'task_list' || op.entityType == 'task_checklist_item'; - final syncAfterDiscard = isTaskOperation + final isSeriesTimingOperation = + op.entityType == 'event' && + (jsonDecode(op.requestJson) as Map)[calendarEventSeriesTimingKey] + is Map; + final syncAfterDiscard = isTaskOperation || isSeriesTimingOperation ? await serializePendingOpsReplay( database: _database, accountId: _accountId, - replay: () => _discardTaskOperation(op.id), + replay: () async { + if (isTaskOperation) return _discardTaskOperation(op.id); + final current = await _database.pendingOpsDao.getOp(op.id); + if (current == null) return false; + _requireOwnedOperation(current); + return _discardUncoordinatedOperation(current); + }, ) : await _discardUncoordinatedOperation(op); if (syncAfterDiscard) { diff --git a/lib/src/features/sync/sync_engine.dart b/lib/src/features/sync/sync_engine.dart index 7cffdf04..8ddfcd90 100644 --- a/lib/src/features/sync/sync_engine.dart +++ b/lib/src/features/sync/sync_engine.dart @@ -183,13 +183,13 @@ class SyncEngine { final now = _now(); for (final item in page.items) { seen.add(item.id); - if (await _hasLocalPendingTaskListMutation(item.id)) { - continue; - } - _remoteImportWroteData = true; - await _database.taskListsDao.upsertTaskList( - taskListFromDto(_accountId, item, now), - ); + await _database.transaction(() async { + if (await _hasLocalPendingTaskListMutation(item.id)) return; + await _database.taskListsDao.upsertTaskList( + taskListFromDto(_accountId, item, now), + ); + _remoteImportWroteData = true; + }); } pageToken = page.nextPageToken; } while (pageToken != null && pageToken.isNotEmpty); @@ -275,13 +275,13 @@ class SyncEngine { final now = _now(); for (final item in page.items) { seen.add(item.id); - if (await _hasLocalPendingTaskMutation(taskListId, item.id)) { - continue; - } - _remoteImportWroteData = true; - await _database.tasksDao.upsertTask( - taskFromDto(_accountId, taskListId, item, now), - ); + await _database.transaction(() async { + if (await _hasLocalPendingTaskMutation(taskListId, item.id)) return; + await _database.tasksDao.upsertTask( + taskFromDto(_accountId, taskListId, item, now), + ); + _remoteImportWroteData = true; + }); } if (page.items.isNotEmpty) { // This page is already committed. Reconcile before any later task, @@ -325,16 +325,34 @@ class SyncEngine { row.id.equals(taskId), )) .getSingleOrNull(); - return row != null && + if (row != null && (row.localDirty || row.pendingDelete || row.pendingMove || - row.localCreated); + row.localCreated)) { + return true; + } + // A cross-list move relocates the projection immediately. Its queued + // source identity must remain protected while replay is blocked, including + // each source in a dependent move chain. Never infer identity from a task + // ID shared by otherwise unrelated lists or accounts. + final move = + await (_database.select(_database.pendingOps) + ..where( + (op) => + op.accountId.equals(_accountId) & + op.operation.equals('move_task') & + op.taskListId.equals(taskListId) & + op.taskId.equals(taskId), + ) + ..limit(1)) + .getSingleOrNull(); + return move != null; } Future _reconcileTaskListMembership(Set seenTaskListIds) async { - final lists = await _database.taskListsDao.listTaskLists(_accountId); await _database.transaction(() async { + final lists = await _database.taskListsDao.listTaskLists(_accountId); for (final list in lists) { if (seenTaskListIds.contains(list.id)) { if (list.serverMissing) { diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index 873c8022..3e00a6b4 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -10,6 +10,7 @@ import 'package:uuid/uuid.dart'; import '../../../dav/ical/ical_document.dart'; import '../../../dav/ical/ical_semantics.dart'; +import '../../../dav/ical/ical_timezone.dart'; import '../../../dav/mutation/dav_conditional_mutation_service.dart'; import '../../../dav/mutation/dav_mutation_patch.dart'; import '../../../dav/mutation/dav_pending_operations.dart'; @@ -1128,15 +1129,20 @@ class TasksRepository { idFactory: _uuid.v4, nowUtc: _nowUtc, ); - final sortOrder = nextcloudTaskSortOrder( - IcalSemanticDocument.parse(object.rawIcs).components.single, - ); + final semantic = IcalSemanticDocument.parse(object.rawIcs); + final master = semantic.components.single; + final sortOrder = nextcloudTaskSortOrder(master); final now = _now(); final localId = 'dav-local-task-${_uuid.v4()}'; final metadata = jsonEncode({ 'transport': 'caldav', 'uid': object.uid, 'localPendingCreate': true, + ...davTaskTemporalMetadata( + start: master.start, + due: master.due, + timeZoneResolver: IcalTimeZoneResolver.fromDocument(semantic), + ), }); await _database.transaction(() async { await _database.tasksDao.upsertTask( @@ -1245,9 +1251,10 @@ class TasksRepository { parentUid: parentUid, nowUtc: _nowUtc(), ); - final duplicatedMaster = IcalSemanticDocument.parse(duplicatedRaw) - .components - .singleWhere((component) => component.recurrenceIdKey == null); + final duplicatedSemantic = IcalSemanticDocument.parse(duplicatedRaw); + final duplicatedMaster = duplicatedSemantic.components.singleWhere( + (component) => component.recurrenceIdKey == null, + ); final sortOrder = nextcloudTaskSortOrder(duplicatedMaster); final localId = 'dav-local-task-${_uuid.v4()}'; final metadata = jsonEncode({ @@ -1255,6 +1262,11 @@ class TasksRepository { 'uid': newUid, 'localPendingCreate': true, 'duplicatedFromUid': sourceUid, + ...davTaskTemporalMetadata( + start: duplicatedMaster.start, + due: duplicatedMaster.due, + timeZoneResolver: IcalTimeZoneResolver.fromDocument(duplicatedSemantic), + ), }); final now = _now(); late final String operationId; @@ -1421,6 +1433,66 @@ class TasksRepository { } if (objectId == null) { await _patchLocalTask(taskList.id, task.id, fields, now); + final currentCreate = await _pendingDavCreateForProjection(task.id); + if (currentCreate == null) { + throw StateError('The pending DAV task create is unavailable.'); + } + final semantic = IcalSemanticDocument.parse( + _pendingDavCreateRawIcs(currentCreate), + ); + final master = semantic.components.singleWhere( + (component) => + component.componentType == 'VTODO' && + component.uid == uid && + component.recurrenceId == null, + ); + final metadata = + Map.from( + jsonDecode(task.providerMetadataJson!) as Map, + ) + ..remove('nativeStart') + ..remove('nativeDue') + ..remove('startUtc') + ..remove('dueUtc'); + metadata.addAll( + davTaskTemporalMetadata( + start: master.start, + due: master.due, + timeZoneResolver: IcalTimeZoneResolver.fromDocument(semantic), + ), + ); + final metadataJson = jsonEncode(metadata); + final temporal = _davNativeTaskFields(metadataJson); + final history = completedDavTaskCurrentInstance(semantic, master); + final state = history?.taskUiState ?? master.taskUiState; + final completed = history?.completed ?? master.completed; + await _writeLocalTask( + taskList.id, + task.id, + TasksCompanion( + providerMetadataJson: Value(metadataJson), + status: Value(switch (state) { + IcalTaskUiState.open => 'needsAction', + IcalTaskUiState.inProgress => 'inProcess', + IcalTaskUiState.completed => 'completed', + IcalTaskUiState.cancelled => 'cancelled', + }), + providerStatus: Value( + history != null ? 'COMPLETED' : master.status, + ), + percentComplete: Value( + history != null ? 100 : master.percentComplete, + ), + completedUtc: Value( + completed?.localValue.toUtc().toIso8601String(), + ), + dueUtc: Value(temporal.dueDateTime ?? temporal.startDateTime), + microsoftDueDateTime: Value(temporal.dueDateTime), + microsoftDueTimeZone: Value(temporal.dueTimeZone), + microsoftStartDateTime: Value(temporal.startDateTime), + microsoftStartTimeZone: Value(temporal.startTimeZone), + ), + ); } else { final account = await (_database.select( _database.accounts, @@ -2797,6 +2869,7 @@ class TasksRepository { } bool _matchesFilter(Task row, TaskViewFilter filter, {TaskList? taskList}) { + if (!isVisibleTaskProjection(row)) return false; if (!filter.showCompleted && row.status == 'completed') { return false; } @@ -3131,12 +3204,19 @@ DavMutationPatch? _buildDavTaskMutationPatch({ component.uid == target.uid && component.recurrenceIdKey == null, ); + final completedHistory = master == null + ? null + : completedDavTaskCurrentInstance(prepared, master); + final completedAt = _requestedDavCompletionDate(fields); + if (completedHistory != null && completedAt == null) { + return preceding; + } if (master == null || master.recurrenceRules.isEmpty || - master.taskUiState == IcalTaskUiState.completed) { + (master.taskUiState == IcalTaskUiState.completed && + completedHistory == null)) { return regular(fields); } - final completedAt = _requestedDavCompletionDate(fields); final completion = buildDavRecurringTaskCompletionPatch( target: target, baselineRawIcs: preparedRawIcs, diff --git a/lib/src/google_calendar/google_calendar_api_client.dart b/lib/src/google_calendar/google_calendar_api_client.dart index f0143a4a..b7ead35a 100644 --- a/lib/src/google_calendar/google_calendar_api_client.dart +++ b/lib/src/google_calendar/google_calendar_api_client.dart @@ -7,6 +7,7 @@ import '../calendar_providers/calendar_provider_capabilities.dart'; import '../calendar_providers/calendar_sync_dto.dart'; import '../calendar_providers/cloud_calendar_client.dart'; import '../core/http/request_dispatch_exception.dart'; +import '../core/time/provider_date_time.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -18,6 +19,7 @@ class GoogleCalendarApiClient implements CloudCalendarClient, CompleteRecurringInstanceClient, + SeriesExceptionClient, PrivateCalendarImportClient, DetailedFreeBusyClient, CalendarListManagementClient { @@ -634,6 +636,124 @@ class GoogleCalendarApiClient originalStart: originalStart, ); + @override + Future> readSeriesExceptions({ + required String calendarId, + required CalendarEventDto master, + }) async { + final uid = master.rawJson['iCalUID']?.toString(); + if (uid == null || uid.isEmpty) { + throw const FormatException('The Google series UID is unavailable.'); + } + final resources = await eventsWithICalUid( + calendarId: calendarId, + iCalUid: uid, + includeCancelled: true, + ); + final result = []; + for (final event in resources.where( + (event) => event.providerRecurringEventId == master.providerEventId, + )) { + result.add( + event.isDeleted + ? event + : await getEvent( + calendarId: calendarId, + eventId: event.providerEventId, + ), + ); + } + return result; + } + + @override + Future findSeriesInstance({ + required String calendarId, + required String recurringEventId, + required DateTime originalStart, + required bool allDay, + required String? timeZone, + }) async { + final key = allDay + ? originalStart.toIso8601String().substring(0, 10) + : providerDateTimeAsUtcInstant( + providerWallTimeIso8601String(originalStart), + timeZone, + )!.toIso8601String(); + final resources = await findImportEventInstances( + calendarId: calendarId, + recurringEventId: recurringEventId, + originalStart: key, + ); + final matches = resources.where((event) { + final original = event.providerOriginalStartKey; + return event.providerRecurringEventId == recurringEventId && + original != null && + (allDay + ? original == key + : DateTime.tryParse(original) == DateTime.tryParse(key)); + }).toList(); + if (matches.length != 1) { + throw const FormatException( + 'The revised Google occurrence is not uniquely identifiable.', + ); + } + return matches.single.isDeleted + ? matches.single + : getEvent( + calendarId: calendarId, + eventId: matches.single.providerEventId, + ); + } + + @override + Future restoreSeriesException({ + required String calendarId, + required CalendarEventDto instance, + required CalendarEventDto exception, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async { + if (exception.isDeleted) { + if (!instance.isDeleted) { + await deleteEvent( + calendarId: calendarId, + eventId: instance.providerEventId, + guestUpdatePolicy: guestUpdatePolicy, + ifMatch: instance.etagOrChangeKey, + ); + } + return googleCalendarEventFromJson(calendarId, { + 'id': instance.providerEventId, + 'recurringEventId': instance.providerRecurringEventId, + 'originalStartTime': instance.rawJson['originalStartTime'], + 'status': 'cancelled', + }); + } + final body = + googleEventMutationToJson( + CalendarEventMutation(providerRaw: exception.rawJson), + ) + ..remove('eventType') + ..['status'] = exception.rawJson['status'] ?? 'confirmed'; + final json = await _requestJson( + 'PATCH', + _uri( + '/calendar/v3/calendars/${_enc(calendarId)}/events/${_enc(instance.providerEventId)}', + query: { + 'supportsAttachments': 'true', + 'conferenceDataVersion': '1', + 'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy), + }, + ), + body: body, + headers: { + if (instance.etagOrChangeKey != null) + 'If-Match': instance.etagOrChangeKey!, + }, + ); + return googleCalendarEventFromJson(calendarId, json); + } + Future> _listEventInstances({ required String calendarId, required String recurringEventId, diff --git a/lib/src/google_tasks/oauth/oauth_service.dart b/lib/src/google_tasks/oauth/oauth_service.dart index e6239a63..98fba4ad 100644 --- a/lib/src/google_tasks/oauth/oauth_service.dart +++ b/lib/src/google_tasks/oauth/oauth_service.dart @@ -58,6 +58,7 @@ abstract interface class GoogleContactsAuthorization { String accountId, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }); Future googleContactsAuthorizationHeader(String accountId); @@ -395,6 +396,7 @@ class OAuthService String accountId, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }) async { final attempt = _attempts.begin(_nowUtc, cancellation); GoogleDesktopCredential? current; @@ -407,10 +409,23 @@ class OAuthService 'The selected Google account is no longer available.', ); } - if (current.tokenSet.scopes.contains(googleContactsWriteScope) || - current.tokenSet.scopes.contains(scope)) { + final parent = await (_persistence!.database.select( + _persistence.database.accounts, + )..where((r) => r.id.equals(accountId))).getSingleOrNull(); + if (!forceAuthorization && + parent?.authState == 'signed_in' && + (current.tokenSet.scopes.contains(googleContactsWriteScope) || + current.tokenSet.scopes.contains(scope))) { + final usable = await attempt.wait(validTokenForAccount(accountId)); + if (!usable.scopes.contains(scope) && + !usable.scopes.contains(googleContactsWriteScope)) { + throw const OAuthException( + 'OAuthContactsConsentRequired', + 'Reconnect to restore contacts permission.', + ); + } if (persistContacts != null) { - final persistence = _persistence!; + final persistence = _persistence; final generation = current.generation; await persistence.run(accountId, () async { attempt.check(); @@ -422,6 +437,13 @@ class OAuthService } await persistence.database.transaction(() async { attempt.check(); + await (_persistence.database.update( + _persistence.database.accounts, + )..where((r) => r.id.equals(accountId))).write( + AccountsCompanion( + grantedScopes: Value(usable.scopes.join(' ')), + ), + ); await persistContacts(); attempt.check(); }); @@ -439,11 +461,12 @@ class OAuthService // Grant metadata and the linked account share the credential commit. // The controller validates against the accepted grant inside this // transaction, rather than the parent's previous scope set. - await (_persistence!.database.update( + await (_persistence.database.update( _persistence.database.accounts, )..where((row) => row.id.equals(accountId))).write( AccountsCompanion( grantedScopes: Value(result.tokenSet.scopes.join(' ')), + authState: const Value('signed_in'), ), ); await persistContacts?.call(); diff --git a/lib/src/ical/ical_event_projection.dart b/lib/src/ical/ical_event_projection.dart index 89fc1a80..8886d028 100644 --- a/lib/src/ical/ical_event_projection.dart +++ b/lib/src/ical/ical_event_projection.dart @@ -145,7 +145,9 @@ ProjectedIcalEvent _projectOccurrence( final categories = component.categories.isEmpty ? master.categories : component.categories; - final alarms = component.alarms.isEmpty ? master.alarms : component.alarms; + // Generated instances use the master as their effective component. A + // detached or THISANDFUTURE component owns its alarm list, including empty. + final alarms = component.alarms; final startUtc = _resolvedUtc(occurrence.start, resolver); final endUtc = occurrence.end == null ? null diff --git a/lib/src/ical/ical_import_service.dart b/lib/src/ical/ical_import_service.dart index 0c0171e4..bd51700c 100644 --- a/lib/src/ical/ical_import_service.dart +++ b/lib/src/ical/ical_import_service.dart @@ -1,9 +1,11 @@ import 'package:drift/drift.dart'; import '../features/maps/domain/location_result.dart'; +import '../core/time/provider_date_time.dart'; import '../dav/dav_errors.dart'; import '../dav/ical/ical_document.dart'; +import '../dav/ical/ical_recurrence.dart'; import '../dav/ical/ical_semantics.dart'; import '../dav/ical/ical_timezone.dart'; import '../dav/nextcloud/nextcloud_native_import.dart'; @@ -362,8 +364,33 @@ _PreparedImportDraft _prepareDraft( end = _draftDateTime(endValue); endTimeZone = _timeZone(endValue); } else if (master.duration != null) { - end = _addWallDuration(startValue, master.duration!.duration); - endTimeZone = _timeZone(startValue); + final derivedEnd = addIcalDuration( + startValue, + master.duration!, + resolver: resolver, + ); + final exactEnd = derivedEnd.resolvedUtc; + final graphNeedsExactEnd = + destination.provider == BusyProvider.microsoft && + exactEnd != null && + providerWallTimeToInstant( + derivedEnd.localValue, + derivedEnd.timeZoneId, + ) != + exactEnd; + end = graphNeedsExactEnd + ? exactEnd + : derivedEnd.resolvedUtc != null && + derivedEnd.kind == IcalTemporalKind.tzidDateTime + ? providerInstantInTimeZone( + derivedEnd.resolvedUtc!, + derivedEnd.timeZoneId, + ) + : _draftDateTime(derivedEnd); + // Graph's dateTimeTimeZone uses wall fields and cannot distinguish the + // second autumn hour. Only that calculated endpoint needs UTC; DTSTART + // and the recurrence retain the resource's authored timezone. + endTimeZone = graphNeedsExactEnd ? 'UTC' : _timeZone(startValue); } else if (allDay) { end = DateTime(start.year, start.month, start.day + 1); } else { @@ -737,19 +764,6 @@ DateTime _draftDateTime(IcalTemporalValue value) { ); } -DateTime _addWallDuration(IcalTemporalValue value, Duration duration) { - final wall = value.localValue.add(duration); - if (value.kind == IcalTemporalKind.utcDateTime) return wall.toUtc(); - return DateTime( - wall.year, - wall.month, - wall.day, - wall.hour, - wall.minute, - wall.second, - ); -} - String? _timeZone(IcalTemporalValue value) => switch (value.kind) { IcalTemporalKind.utcDateTime => 'UTC', IcalTemporalKind.tzidDateTime => value.timeZoneId, diff --git a/lib/src/l10n/contacts_error_description.dart b/lib/src/l10n/contacts_error_description.dart new file mode 100644 index 00000000..84e79928 --- /dev/null +++ b/lib/src/l10n/contacts_error_description.dart @@ -0,0 +1,42 @@ +import 'package:busystack_contacts/busystack_contacts.dart'; + +import '../../l10n/generated/app_localizations.dart'; +import '../core/auth/oauth_models.dart'; +import '../core/secrets/secret_store.dart'; +import 'oauth_error_description.dart'; + +String localizedContactsError(AppLocalizations l10n, Object error) { + if (error is OAuthException || error is SecretStoreException) { + return localizedAuthorizationError(l10n, error); + } + if (error is ContactsException) { + return error.code.startsWith('oauth:') + ? localizedContactsStatus(l10n, error.code) + : _failure(l10n, error.kind.name); + } + return l10n.contactsSyncFailed; +} + +String localizedContactsStatus(AppLocalizations l10n, String code) { + if (code.startsWith('oauth:')) { + final kind = OAuthFailureKind.values + .where((kind) => kind.name == code.substring(6)) + .firstOrNull; + return localizedOAuthFailure(l10n, kind, l10n.contactsSyncFailed); + } + if (code.startsWith('contacts:')) return _failure(l10n, code.split(':')[1]); + if (code == 'reconnect-required' || code == 'linked-parent-removed') { + return l10n.accountReconnectRequired; + } + return l10n.contactsSyncFailed; +} + +String _failure(AppLocalizations l10n, String kind) => switch (kind) { + 'authentication' => l10n.accountReconnectRequired, + 'permission' => l10n.oauthPermissionRefused, + 'offline' => l10n.networkOfflineTryAgain, + 'throttled' => l10n.oauthProviderThrottled, + 'validation' => l10n.invalidCollectionValue, + 'unknownOutcome' => l10n.nextcloudRefreshPending, + _ => l10n.contactsSyncFailed, +}; diff --git a/lib/src/l10n/nextcloud_collection_error_description.dart b/lib/src/l10n/nextcloud_collection_error_description.dart new file mode 100644 index 00000000..0d57e1e3 --- /dev/null +++ b/lib/src/l10n/nextcloud_collection_error_description.dart @@ -0,0 +1,39 @@ +import '../../l10n/generated/app_localizations.dart'; +import '../core/secrets/secret_store.dart'; +import '../dav/dav_errors.dart'; +import '../dav/nextcloud/nextcloud_collection_service.dart'; + +String? localizedNextcloudFieldError( + AppLocalizations l10n, + NextcloudCollectionValidation? reason, +) => switch (reason) { + NextcloudCollectionValidation.required => l10n.requiredField, + NextcloudCollectionValidation.color => l10n.invalidCollectionColor, + NextcloudCollectionValidation.order => l10n.invalidCollectionOrder, + NextcloudCollectionValidation.timezone => l10n.invalidCollectionTimezone, + NextcloudCollectionValidation.value => l10n.invalidCollectionValue, + null => null, +}; + +String localizedNextcloudCollectionError( + AppLocalizations l10n, + Object? error, +) => switch (error) { + NextcloudCollectionValidationException() => l10n.invalidCollectionValue, + SecretStoreException() => l10n.oauthSecureStorageUnavailable, + DavException(kind: DavErrorKind.authentication) => + l10n.accountReconnectRequired, + DavException(kind: DavErrorKind.authorization) => + l10n.nextcloudOperationDenied, + DavException( + kind: DavErrorKind.network || + DavErrorKind.timeout || + DavErrorKind.server || + DavErrorKind.tls, + ) => + l10n.nextcloudServerUnavailable, + DavException(kind: DavErrorKind.unknownOutcome) => + l10n.nextcloudRefreshPending, + DavException() => l10n.nextcloudUnsupported, + _ => l10n.operationFailed, +}; diff --git a/lib/src/l10n/oauth_error_description.dart b/lib/src/l10n/oauth_error_description.dart index e32d6d33..cabf6c7f 100644 --- a/lib/src/l10n/oauth_error_description.dart +++ b/lib/src/l10n/oauth_error_description.dart @@ -27,7 +27,9 @@ String localizedOAuthFailure( OAuthFailureKind? kind, String fallback, ) => switch (kind) { - OAuthFailureKind.registrationUnavailable || + OAuthFailureKind.alreadyConnected => l10n.accountAlreadyConnected, + OAuthFailureKind.registrationUnavailable => + l10n.registrationRecoveryDescription, OAuthFailureKind.expiredGrant => l10n.accountReconnectRequired, OAuthFailureKind.identityUnverified => l10n.googleIdentityUnverified, OAuthFailureKind.clientRejected => l10n.oauthRegistrationRejected, @@ -39,7 +41,8 @@ String localizedOAuthFailure( OAuthFailureKind.temporary => l10n.oauthProviderTemporaryFailure, OAuthFailureKind.timeout => l10n.oauthAuthorizationTimedOut, OAuthFailureKind.wrongAccount => l10n.oauthWrongAccount, - OAuthFailureKind.storage => l10n.oauthSecureStorageUnavailable, + OAuthFailureKind.storage || + OAuthFailureKind.corruptRecord => l10n.oauthSecureStorageUnavailable, _ => fallback, }; diff --git a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart index e60dcb4f..d328aacd 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import '../calendar_providers/attachment_upload_session.dart'; import 'package:http/http.dart' as http; +import 'package:collection/collection.dart'; import '../calendar_providers/calendar_mutation.dart'; import '../calendar_providers/calendar_provider_capabilities.dart'; @@ -20,7 +21,10 @@ import 'microsoft_event_attachment.dart'; import 'microsoft_shared_calendar_address.dart'; class MicrosoftCalendarApiClient - implements CloudCalendarClient, DetailedFreeBusyClient { + implements + CloudCalendarClient, + DetailedFreeBusyClient, + SeriesExceptionClient { MicrosoftCalendarApiClient({ required http.Client httpClient, required Uri baseUri, @@ -721,6 +725,150 @@ class MicrosoftCalendarApiClient ); } + @override + Future> readSeriesExceptions({ + required String calendarId, + required CalendarEventDto master, + }) async { + final snapshot = await getSeriesExceptionSnapshot( + calendarId: calendarId, + recurringEventId: master.providerEventId, + ); + if (snapshot.cancelledIds.isNotEmpty) { + // Graph v1.0 supplies opaque occurrence IDs for cancellations and omits + // them from /instances. It exposes no writable originalStart or + // documented conversion of those IDs to recurrence positions. + throw const SeriesExceptionPreservationUnsupported( + 'Microsoft returned cancellation IDs without recurrence positions that this v1.0 operation can safely resolve. ' + 'The series was not changed; a decision about retaining those cancellations is required before retrying.', + ); + } + final result = []; + for (final partial in snapshot.exceptions) { + final event = await getEvent( + calendarId: calendarId, + eventId: partial.providerEventId, + ); + final meeting = event.rawJson['onlineMeeting']; + final sharedMeeting = + master.rawJson['isOnlineMeeting'] == true && + meeting is Map && + meeting['joinUrl'] is String && + const DeepCollectionEquality().equals( + meeting, + master.rawJson['onlineMeeting'], + ); + if (event.rawJson['hasAttachments'] == true || + event.rawJson['isOnlineMeeting'] == true && !sharedMeeting) { + throw const SeriesExceptionPreservationUnsupported( + 'This Microsoft exception has attachment resources or its own online-meeting identity. ' + 'The recurrence update cannot reassign those resources, so BusyMax kept the original series and this edit remains pending.', + ); + } + result.add(event); + } + return result; + } + + @override + Future findSeriesInstance({ + required String calendarId, + required String recurringEventId, + required DateTime originalStart, + required bool allDay, + required String? timeZone, + }) async { + final instant = providerWallTimeToInstant(originalStart, timeZone); + final snapshot = await getSeriesExceptionSnapshot( + calendarId: calendarId, + recurringEventId: recurringEventId, + ); + final exceptions = snapshot.exceptions + .where( + (event) => + DateTime.tryParse(event.providerOriginalStartKey ?? '') == + instant, + ) + .toList(); + if (exceptions.length == 1) { + return getEvent( + calendarId: calendarId, + eventId: exceptions.single.providerEventId, + ); + } + final instances = await listEventInstances( + calendarId: calendarId, + recurringEventId: recurringEventId, + rangeStart: instant, + rangeEnd: instant.add(const Duration(days: 1)), + ); + final matches = instances + .where( + (event) => + event.providerRecurringEventId == recurringEventId && + DateTime.tryParse(event.providerOriginalStartKey ?? '') == + instant, + ) + .toList(); + if (matches.length != 1) { + throw const FormatException( + 'The revised Microsoft occurrence is not uniquely identifiable.', + ); + } + return getEvent( + calendarId: calendarId, + eventId: matches.single.providerEventId, + ); + } + + @override + Future restoreSeriesException({ + required String calendarId, + required CalendarEventDto instance, + required CalendarEventDto exception, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async { + if (exception.rawJson['isOnlineMeeting'] == true && + !const DeepCollectionEquality().equals( + exception.rawJson['onlineMeeting'], + instance.rawJson['onlineMeeting'], + )) { + throw const SeriesExceptionPreservationUnsupported( + 'Microsoft did not retain the series meeting identity on the revised occurrence. The original exception has not been replaced.', + ); + } + const writable = { + 'subject', + 'body', + 'start', + 'end', + 'isAllDay', + 'attendees', + 'categories', + 'location', + 'locations', + 'importance', + 'sensitivity', + 'showAs', + 'isReminderOn', + 'reminderMinutesBeforeStart', + 'responseRequested', + 'hideAttendees', + 'allowNewTimeProposals', + }; + final json = await _requestJson( + 'PATCH', + _uri( + '${_calendarPath(calendarId)}/events/${_enc(instance.providerEventId)}', + ), + body: { + for (final entry in exception.rawJson.entries) + if (writable.contains(entry.key)) entry.key: entry.value, + }, + ); + return microsoftCalendarEventFromJson(calendarId, json); + } + @override Future syncEvents({ required String calendarId, diff --git a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart index 8dbbd50a..b912f6cd 100644 --- a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart +++ b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart @@ -60,6 +60,7 @@ abstract interface class MicrosoftContactsAuthorization { String accountId, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }); Future microsoftContactsAuthorizationHeader( @@ -565,11 +566,13 @@ class MicrosoftOAuthService String accountId, { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }) => _optionalConsent( accountId, microsoftContactsReadScope, cancellation, persistContacts: persistContacts, + forceAuthorization: forceAuthorization, ); @override @@ -602,20 +605,38 @@ class MicrosoftOAuthService String scope, AuthorizationCancellation? cancellation, { Future Function()? persistContacts, + bool forceAuthorization = false, }) async { final attempt = _attempts.begin(_nowUtc, cancellation); MicrosoftDesktopCredential? current; try { final active = await attempt.wait(boundCredentialForAccount(id)); current = active; - if (hasMicrosoftGraphScope(active.tokenSet.scopes, scope) || - scope == microsoftContactsReadScope && - hasMicrosoftGraphScope( - active.tokenSet.scopes, - microsoftContactsWriteScope, - )) { + final parent = await (_persistence!.database.select( + _persistence.database.accounts, + )..where((r) => r.id.equals(id))).getSingleOrNull(); + if (!forceAuthorization && + parent?.authState == 'signed_in' && + (hasMicrosoftGraphScope(active.tokenSet.scopes, scope) || + scope == microsoftContactsReadScope && + hasMicrosoftGraphScope( + active.tokenSet.scopes, + microsoftContactsWriteScope, + ))) { + final usable = await attempt.wait(validTokenForAccount(id)); + if (!hasMicrosoftGraphScope(usable.scopes, scope) && + !(scope == microsoftContactsReadScope && + hasMicrosoftGraphScope( + usable.scopes, + microsoftContactsWriteScope, + ))) { + throw const OAuthException( + 'MicrosoftOAuthContactsConsentRequired', + 'Reconnect to restore the requested permission.', + ); + } if (persistContacts != null) { - final persistence = _persistence!; + final persistence = _persistence; final generation = active.generation; await persistence.run(id, () async { attempt.check(); @@ -627,6 +648,13 @@ class MicrosoftOAuthService } await persistence.database.transaction(() async { attempt.check(); + await (_persistence.database.update( + _persistence.database.accounts, + )..where((r) => r.id.equals(id))).write( + AccountsCompanion( + grantedScopes: Value(usable.scopes.join(' ')), + ), + ); await persistContacts(); attempt.check(); }); @@ -641,11 +669,12 @@ class MicrosoftOAuthService capturedAttempt: attempt, ); await result.commit!(() async { - await (_persistence!.database.update( + await (_persistence.database.update( _persistence.database.accounts, )..where((row) => row.id.equals(id))).write( AccountsCompanion( grantedScopes: Value(result.tokenSet.scopes.join(' ')), + authState: const Value('signed_in'), ), ); await persistContacts?.call(); diff --git a/lib/src/schedule/schedule_event_rescheduling.dart b/lib/src/schedule/schedule_event_rescheduling.dart index 01816282..941f4dbf 100644 --- a/lib/src/schedule/schedule_event_rescheduling.dart +++ b/lib/src/schedule/schedule_event_rescheduling.dart @@ -6,6 +6,7 @@ import '../features/calendar/data/calendar_event_detail.dart'; import '../features/calendar/data/calendar_repository.dart'; import '../features/calendar/domain/event_timing_policy.dart'; import '../features/calendar/presentation/event_editor_draft.dart'; +import '../features/recurrence/domain/series_timing_change.dart'; import 'schedule_item.dart'; enum ScheduleTimingAction { move, resizeStart, resizeEnd } @@ -172,11 +173,17 @@ final class ScheduleReschedulingCoordinator { required this.chooseScope, required this.chooseGuestUpdates, required this.requestSync, + this.confirmSeriesTimingChange, }); final CalendarRepository repository; final ScheduleScopeSelector chooseScope; final ScheduleGuestSelector chooseGuestUpdates; final Future Function(String accountId) requestSync; + final Future Function( + SeriesTimingChange change, + RecurringEventMutationScope scope, + )? + confirmSeriesTimingChange; Future commit( ScheduleRescheduleRequest request, { @@ -263,12 +270,25 @@ final class ScheduleReschedulingCoordinator { return providerInstantInTimeZone(value.toUtc(), zone); } - final changed = draft.copyWith( + var changed = draft.copyWith( start: endpoint(request.interval.start, detail.startTimeZone), end: endpoint(request.interval.end, detail.endTimeZone), recurringMutationScope: scope, ); if (!active()) return ScheduleRescheduleResult.cancelled; + final change = await repository.prepareSeriesTimingChange( + changed, + timingOnly: true, + ); + if (!active()) return ScheduleRescheduleResult.cancelled; + if (change?.requiresConfirmation == true) { + final confirm = confirmSeriesTimingChange; + if (confirm == null) throw SeriesTimingConfirmationRequired(change!); + if (!await confirm(change!, scope!) || !active()) { + return ScheduleRescheduleResult.cancelled; + } + changed = changed.copyWith(seriesTimingApproval: change.signature); + } var result = ScheduleRescheduleResult.saved; try { await repository.updateLocalEvent( diff --git a/lib/src/schedule/schedule_projection.dart b/lib/src/schedule/schedule_projection.dart index ec26a76f..20879459 100644 --- a/lib/src/schedule/schedule_projection.dart +++ b/lib/src/schedule/schedule_projection.dart @@ -40,15 +40,30 @@ class ScheduleProjection { } static Map> groupByDay( - List items, - ) { + List items, { + ScheduleRange? visibleRange, + bool repeatCalendarEvents = false, + }) { final groups = >{}; for (final item in items) { final start = item.start; if (start == null) { continue; } - final key = day(start); + var key = day(start); + if (item is CalendarScheduleItem && visibleRange != null) { + if (!intersects(item, visibleRange)) continue; + final firstVisibleDay = day(visibleRange.start); + if (key.isBefore(firstVisibleDay)) key = firstVisibleDay; + if (repeatCalendarEvents) { + final end = item.end ?? start.add(const Duration(minutes: 1)); + while (key.isBefore(visibleRange.end) && key.isBefore(end)) { + groups.putIfAbsent(key, () => []).add(item); + key = DateTime(key.year, key.month, key.day + 1); + } + continue; + } + } groups.putIfAbsent(key, () => []).add(item); } for (final key in groups.keys.toList()) { diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index f389f9c7..a40b590d 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -370,7 +370,8 @@ class ScheduleRepository { ) ..where( _database.taskLists.id.isNull() | - _database.taskLists.serverMissing.equals(false), + (_database.taskLists.serverMissing.equals(false) & + _database.taskLists.pendingDelete.equals(false)), ) ..where( _database.taskLists.davCollectionId.isNull() | @@ -380,7 +381,9 @@ class ScheduleRepository { query.where(_taskListFilter(filters.taskListKeys)); } - final rows = await query.get(); + final rows = (await query.get()) + .where((row) => isVisibleTaskProjection(row.readTable(_database.tasks))) + .toList(); if (rows.isEmpty) return List.of(items); final tasks = []; @@ -710,7 +713,8 @@ class ScheduleRepository { ) ..where( _database.taskLists.id.isNull() | - _database.taskLists.serverMissing.equals(false), + (_database.taskLists.serverMissing.equals(false) & + _database.taskLists.pendingDelete.equals(false)), ) ..where( _database.taskLists.davCollectionId.isNull() | @@ -733,7 +737,9 @@ class ScheduleRepository { _database.tasks.davCollectionId.isNotNull(); query.where(filters.showNoDateTasks ? inRange | _taskNoDate() : inRange); } - final rows = await query.get(); + final rows = (await query.get()) + .where((row) => isVisibleTaskProjection(row.readTable(_database.tasks))) + .toList(); final hierarchy = await _taskHierarchy(rows); final items = []; for (final row in rows) { @@ -816,7 +822,8 @@ class ScheduleRepository { ) ..where( _database.taskLists.id.isNull() | - _database.taskLists.serverMissing.equals(false), + (_database.taskLists.serverMissing.equals(false) & + _database.taskLists.pendingDelete.equals(false)), ) ..where( _database.taskLists.davCollectionId.isNull() | @@ -848,7 +855,9 @@ class ScheduleRepository { OrderingTerm.asc(_database.tasks.title), ]); - final rows = await query.get(); + final rows = (await query.get()) + .where((row) => isVisibleTaskProjection(row.readTable(_database.tasks))) + .toList(); final hierarchy = await _taskHierarchy(rows); final items = []; for (final row in rows) { @@ -1003,9 +1012,17 @@ class ScheduleRepository { _database.tasks.microsoftDueDateTime.isNull(); } + // Written provider dates are not local dates for timed Microsoft tasks. + // SQLite cannot resolve their named zones. Keep these candidates for the + // existing instant-aware projection/filter, as we already do for DAV rows. + Expression _providerTimedTask() => + _database.tasks.microsoftStartDateTime.like('%T%') | + _database.tasks.microsoftDueDateTime.like('%T%'); + Expression _taskScheduledBefore(DateTime before) { final beforeKey = _dateKey(before); - return _textBefore(_database.tasks.dueUtc, beforeKey) | + return _providerTimedTask() | + _textBefore(_database.tasks.dueUtc, beforeKey) | _textBefore(_database.tasks.microsoftStartDateTime, beforeKey) | _textBefore(_database.tasks.microsoftDueDateTime, beforeKey); } @@ -1013,7 +1030,8 @@ class ScheduleRepository { Expression _taskScheduledInRange(ScheduleRange range) { final startKey = _dateKey(range.start); final endKey = _dateKey(range.end); - return _textInRange(_database.tasks.dueUtc, startKey, endKey) | + return _providerTimedTask() | + _textInRange(_database.tasks.dueUtc, startKey, endKey) | _textInRange(_database.tasks.microsoftStartDateTime, startKey, endKey) | _textInRange(_database.tasks.microsoftDueDateTime, startKey, endKey); } @@ -1209,8 +1227,14 @@ DateTime? _taskDue(Task task, BusyProvider provider) { DateTime? _taskStart(Task task, BusyProvider provider) { if (provider == BusyProvider.microsoft) { - return _parseDateTime(task.microsoftStartDateTime) ?? - _parseDateTime(task.microsoftDueDateTime) ?? + return providerDateTimeAsLocal( + task.microsoftStartDateTime, + task.microsoftStartTimeZone, + ) ?? + providerDateTimeAsLocal( + task.microsoftDueDateTime, + task.microsoftDueTimeZone, + ) ?? _parseDate(task.dueUtc); } if (_isDavProvider(provider)) { @@ -1226,7 +1250,7 @@ DateTime? _taskEnd(Task task, BusyProvider provider) { return null; } if (_taskAllDay(task, provider)) { - return start.add(const Duration(days: 1)); + return DateTime(start.year, start.month, start.day + 1); } return start.add(const Duration(minutes: 30)); } diff --git a/lib/src/ui/windows/windows_account_removal_dialog.dart b/lib/src/ui/windows/windows_account_removal_dialog.dart index 9729c2f3..1af32717 100644 --- a/lib/src/ui/windows/windows_account_removal_dialog.dart +++ b/lib/src/ui/windows/windows_account_removal_dialog.dart @@ -12,6 +12,7 @@ Future showWindowsAccountRemovalDialog( BuildContext context, { required String accountLabel, required bool canRevokeGoogleAuthorization, + bool revokeNextcloudAppPassword = false, }) { var revoke = false; return showDialog( @@ -27,6 +28,8 @@ Future showWindowsAccountRemovalDialog( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.removeAccountConfirmation), + if (revokeNextcloudAppPassword) + Text(l10n.nextcloudRemovalAuthorizationDescription), if (canRevokeGoogleAuthorization) ...[ const SizedBox(height: 16), Checkbox( diff --git a/lib/src/ui/windows/windows_contacts_settings.dart b/lib/src/ui/windows/windows_contacts_settings.dart index 514d21c2..7383c827 100644 --- a/lib/src/ui/windows/windows_contacts_settings.dart +++ b/lib/src/ui/windows/windows_contacts_settings.dart @@ -1,4 +1,5 @@ import '../../l10n/l10n.dart'; +import '../../l10n/contacts_error_description.dart'; import 'dart:async'; @@ -32,11 +33,7 @@ class _WindowsContactsSettingsState await action(); } on Object catch (error) { if (mounted) { - setState( - () => _error = error is ContactsException - ? error.code - : context.l10n.operationFailed, - ); + setState(() => _error = localizedContactsError(context.l10n, error)); } } finally { if (mounted) setState(() => _pending = false); @@ -119,7 +116,11 @@ class _WindowsContactsSettingsState ].contains(p.provider), )) ...[ Text('${context.l10n.contactsTitle}: ${parent.displayLabel}'), - Text(context.l10n.contactsReadPermissionDescription), + Text( + parent.provider == BusyProvider.nextcloud + ? context.l10n.contactsNextcloudReuse + : context.l10n.contactsReadPermissionDescription, + ), _action( context.l10n.contactsEnableSuggestions, () => parent.provider == BusyProvider.nextcloud @@ -138,12 +139,18 @@ class _WindowsContactsSettingsState Text( '${account.displayName} · ${context.l10n.contactsReadOnlyUse} · ${account.enabled ? context.l10n.contactsSuggestionsEnabled : context.l10n.off}', ), - if (account.errorCode != null) Text(account.errorCode!), + if (account.errorCode != null) + Text(localizedContactsStatus(context.l10n, account.errorCode!)), Wrap( spacing: 8, children: [ + if (account.enabled) + _action( + context.l10n.contactsRetrySync, + () => controller.synchronizeAccount(account.id), + ), _action( - context.l10n.connectAccountAction, + context.l10n.reconnectAccount, () => _reconnect(account), ), _action( diff --git a/lib/src/ui/windows/windows_event_editor_dialog.dart b/lib/src/ui/windows/windows_event_editor_dialog.dart index 6c1f134a..d1f04799 100644 --- a/lib/src/ui/windows/windows_event_editor_dialog.dart +++ b/lib/src/ui/windows/windows_event_editor_dialog.dart @@ -1,3 +1,6 @@ +import '../../features/recurrence/domain/series_timing_change.dart'; +import '../../features/recurrence/presentation/recurrence_summary.dart'; + import 'package:busymax/src/core/auth/authorization_attempt.dart'; import 'dart:async'; @@ -1869,6 +1872,67 @@ Future showWindowsEventEditorDialog( } guestUpdatePolicy = choice; } + final seriesTiming = await repository + .prepareSeriesTimingChange(draft); + if (!dialogContext.mounted) return; + if (seriesTiming?.requiresConfirmation == true) { + final confirmed = await showDialog( + context: dialogContext, + builder: (confirmationContext) => ContentDialog( + title: Text(l10n.repeat), + content: Text( + l10n.seriesPatternChangeConfirmation( + recurrenceRuleSummary( + confirmationContext, + seriesTiming!.previewRule( + following: + draft.recurringMutationScope == + RecurringEventMutationScope + .thisAndFuture, + original: true, + ), + timeZone: seriesTiming.timeZone, + ), + recurrenceRuleSummary( + confirmationContext, + seriesTiming.previewRule( + following: + draft.recurringMutationScope == + RecurringEventMutationScope + .thisAndFuture, + ), + timeZone: seriesTiming.timeZone, + ), + ), + ), + actions: [ + Button( + onPressed: () => Navigator.pop( + confirmationContext, + false, + ), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop( + confirmationContext, + true, + ), + child: Text(l10n.save), + ), + ], + ), + ); + if (confirmed != true) { + if (dialogContext.mounted) { + setState(() => saving = false); + } + return; + } + draft = draft.copyWith( + seriesTimingApproval: seriesTiming!.signature, + ); + } if (detail == null) { await repository.createLocalEvent( draft, @@ -1892,10 +1956,12 @@ Future showWindowsEventEditorDialog( } saved = true; closeDialog(); - } on Object catch (_) { + } on Object catch (failure) { setState(() { saving = false; - error = l10n.operationFailed; + error = failure is SeriesTimingDecisionRequired + ? failure.message + : l10n.operationFailed; }); } }, diff --git a/lib/src/ui/windows/windows_nextcloud_dialogs.dart b/lib/src/ui/windows/windows_nextcloud_dialogs.dart index 44ad0da6..1209a587 100644 --- a/lib/src/ui/windows/windows_nextcloud_dialogs.dart +++ b/lib/src/ui/windows/windows_nextcloud_dialogs.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import '../../l10n/nextcloud_collection_error_description.dart'; + import '../common/schedule/native_collection_export.dart'; import 'windows_nextcloud_scheduling_dialog.dart'; @@ -118,7 +120,21 @@ class _WindowsNextcloudCollectionDialogState ); return Padding( padding: const EdgeInsets.only(bottom: 12), - child: InfoLabel(label: label, child: input), + child: InfoLabel( + label: label, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + input, + if (localizedNextcloudFieldError( + AppLocalizations.of(context), + model.fieldErrors[property], + ) + case final message?) + Text(message), + ], + ), + ), ); } @@ -176,11 +192,7 @@ class _WindowsNextcloudCollectionDialogState Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( - model.error is DavException && - (model.error! as DavException).kind == - DavErrorKind.authorization - ? l10n.nextcloudOperationDenied - : l10n.nextcloudServerUnavailable, + localizedNextcloudCollectionError(l10n, model.error), ), ), if (model.refreshPending) Text(l10n.nextcloudRefreshPending), diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart index 352c1d2b..9a35254e 100644 --- a/lib/src/ui/windows/windows_schedule_page.dart +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -36,6 +36,7 @@ import '../../features/maps/application/external_location_launcher.dart'; import '../../features/schedule/application/saved_schedule_location.dart'; import '../../schedule/schedule_event_rescheduling.dart'; import '../../features/calendar/domain/event_timing_policy.dart'; +import '../../features/recurrence/presentation/recurrence_summary.dart'; import '../../features/tasks/data/tasks_repository.dart'; import '../../features/tasks/domain/task_mutation_result.dart'; import '../common/schedule/schedule_interactions.dart'; @@ -886,6 +887,45 @@ class _WindowsSchedulePageState extends ConsumerState { try { final result = await ScheduleReschedulingCoordinator( repository: ref.read(calendarRepositoryProvider), + confirmSeriesTimingChange: (change, scope) async { + if (!mounted) return false; + final following = scope == RecurringEventMutationScope.thisAndFuture; + return await showDialog( + context: context, + builder: (confirmationContext) => ContentDialog( + title: Text(l10n.repeat), + content: Text( + l10n.seriesPatternChangeConfirmation( + recurrenceRuleSummary( + confirmationContext, + change.previewRule( + following: following, + original: true, + ), + timeZone: change.timeZone, + ), + recurrenceRuleSummary( + confirmationContext, + change.previewRule(following: following), + timeZone: change.timeZone, + ), + ), + ), + actions: [ + Button( + onPressed: () => + Navigator.pop(confirmationContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(confirmationContext, true), + child: Text(l10n.save), + ), + ], + ), + ) == + true; + }, chooseScope: (detail, following) => showWindowsRecurringEventMutationScope( context, diff --git a/lib/src/ui/windows/windows_settings_page.dart b/lib/src/ui/windows/windows_settings_page.dart index 9a75c7a0..76cb1092 100644 --- a/lib/src/ui/windows/windows_settings_page.dart +++ b/lib/src/ui/windows/windows_settings_page.dart @@ -1306,7 +1306,10 @@ Future _removeWindowsAccount( final l10n = AppLocalizations.of(context); final options = await showWindowsAccountRemovalDialog( context, - accountLabel: account.displayLabel, + accountLabel: account.provider == BusyProvider.nextcloud + ? '${account.displayLabel} · ${account.authority}' + : account.displayLabel, + revokeNextcloudAppPassword: account.provider == BusyProvider.nextcloud, canRevokeGoogleAuthorization: account.provider == BusyProvider.google && account.isSignedIn, ); @@ -1331,8 +1334,11 @@ Future _removeWindowsAccount( final remaining = await ref .read(accountsRepositoryProvider) .listVisibleAccounts(); - ref.read(selectedAccountIdProvider.notifier).state = - remaining.firstOrNull?.id; + final selected = ref.read(selectedAccountIdProvider); + if (selected == account.id || !remaining.any((a) => a.id == selected)) { + ref.read(selectedAccountIdProvider.notifier).state = + remaining.firstOrNull?.id; + } await ref.read(authSessionControllerProvider.notifier).load(); if (!context.mounted) return; if (authResult?.authorizationRevocationFailed ?? false) { @@ -1843,11 +1849,7 @@ class _WindowsRegistrationCardState _authorizationCancellation = cancellation; try { AuthorizationRequest request; - if (replace || - account.provider == BusyProvider.google && - (account.needsReconnect || - summary == null || - summary.origin == RegistrationOrigin.retiringShared)) { + if (replace) { final handle = await showWindowsRegistrationSetup( context, ref, @@ -1978,15 +1980,7 @@ class _WindowsRegistrationCardState ), Button( onPressed: connecting ? null : () => connect(false), - child: Text( - account.needsReconnect || - summary == null || - summary.origin == RegistrationOrigin.retiringShared - ? (account.provider == BusyProvider.google - ? l10n.reconnectGoogleAccount - : l10n.reconnectAccount) - : l10n.connectAccountAction, - ), + child: Text(l10n.reconnectAccount), ), ], ), diff --git a/lib/src/webcal/webcal_subscription_service.dart b/lib/src/webcal/webcal_subscription_service.dart index f6fbe93a..9d56e1ee 100644 --- a/lib/src/webcal/webcal_subscription_service.dart +++ b/lib/src/webcal/webcal_subscription_service.dart @@ -21,7 +21,7 @@ import 'webcal_http_client.dart'; import 'webcal_uri.dart'; const webCalParserVersion = 1; -const webCalProjectionVersion = 2; +const webCalProjectionVersion = 3; enum WebCalRefreshMode { automatic, hourly, sixHours, daily } diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 33d73846..c92ad446 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -96,6 +96,9 @@ + +

Beta maintenance release.

+

Recover Google account authorization while preserving local data, and build official Linux Snaps in an isolated local workspace.

diff --git a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt index f508ae18..26120d1c 100644 --- a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt +++ b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt @@ -33,6 +33,7 @@ import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.api.Scope import com.microsoft.identity.client.AcquireTokenParameters import com.microsoft.identity.client.AcquireTokenSilentParameters +import com.microsoft.identity.client.IAccount import com.microsoft.identity.client.AuthenticationCallback import com.microsoft.identity.client.IAuthenticationResult import com.microsoft.identity.client.IMultipleAccountPublicClientApplication @@ -58,6 +59,21 @@ import java.util.concurrent.Executors import java.util.concurrent.Semaphore import java.util.concurrent.TimeUnit +internal fun microsoftSilentParameters( + call: MethodCall, + scopes: List, + account: IAccount, + authority: String, +): AcquireTokenSilentParameters { + val builder = AcquireTokenSilentParameters.Builder() + .withScopes(scopes).forAccount(account).fromAuthority(authority) + .forceRefresh(call.argument("forceRefresh") == true) + call.argument("claims")?.takeIf { it.isNotBlank() }?.let { + builder.withClaims(ClaimsRequest.getClaimsRequestFromJsonString(it)) + } + return builder.build() +} + internal object EngineOwnedAccountGateRegistry { internal data class Lease(val ownerId: String, val semaphore: Semaphore) @@ -512,12 +528,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, try { val account = application.accounts.firstOrNull { it.id == nativeId } ?: throw MicrosoftAccountMissingException() - val parametersBuilder = AcquireTokenSilentParameters.Builder() - .withScopes(scopes).forAccount(account).fromAuthority(selectedAuthority ?: account.authority) - call.argument("claims")?.takeIf { it.isNotBlank() }?.let { - parametersBuilder.withClaims(ClaimsRequest.getClaimsRequestFromJsonString(it)) - } - val parameters = parametersBuilder.build() + val parameters = microsoftSilentParameters(call, scopes, account, selectedAuthority ?: account.authority) postSuccess(result, msalResult(application.acquireTokenSilent(parameters))) } catch (error: Exception) { val failure = classifyMicrosoftSilentFailure(error) diff --git a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt index 055259b0..2646d524 100644 --- a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt +++ b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt @@ -4,6 +4,7 @@ import androidx.core.text.util.LocalePreferences import com.microsoft.identity.client.exception.MsalClientException import com.microsoft.identity.client.exception.MsalServiceException import com.microsoft.identity.client.exception.MsalUiRequiredException +import com.microsoft.identity.client.IAccount import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.util.Locale @@ -14,6 +15,34 @@ import sun.misc.Unsafe import org.mockito.Mockito internal class BusymaxAndroidPlatformPluginTest { + @Test + fun silentRecovery_propagatesForceRefreshIntoPinnedMsalParameters() { + val account = object : IAccount { + override fun getId() = "qa-account" + override fun getAuthority() = "https://login.microsoftonline.com/tenant" + override fun getIdToken() = "qa-id-token" + override fun getClaims(): Map = emptyMap() + override fun getUsername() = "qa@example.test" + override fun getTenantId() = "tenant" + } + val scopes = listOf("User.Read", "Tasks.ReadWrite", "Calendars.ReadWrite.Shared") + val authority = "https://login.microsoftonline.com/tenant" + for (refresh in listOf(false, true)) { + val call = MethodCall("authorizeMicrosoftSilent", mapOf( + "forceRefresh" to refresh, + "claims" to "{\"access_token\":{\"example\":{\"essential\":true}}}", + )) + val parameters = microsoftSilentParameters(call, scopes, account, authority) + assertEquals(refresh, parameters.forceRefresh) + assertEquals(account, parameters.account) + assertEquals(scopes, parameters.scopes) + assertEquals(authority, parameters.authority) + assertNotNull(parameters.claimsRequest) + } + val ordinary = microsoftSilentParameters(MethodCall("authorizeMicrosoftSilent", emptyMap()), scopes, account, authority) + assertEquals(false, ordinary.forceRefresh) + } + @Test fun scopedCancellation_cannotCancelNewerInteractiveReservation() { val plugin = BusymaxAndroidPlatformPlugin() diff --git a/packages/busymax_android_platform/lib/busymax_android_platform.dart b/packages/busymax_android_platform/lib/busymax_android_platform.dart index 363a8af2..7cddce86 100644 --- a/packages/busymax_android_platform/lib/busymax_android_platform.dart +++ b/packages/busymax_android_platform/lib/busymax_android_platform.dart @@ -95,6 +95,7 @@ final class BusyMaxAndroidPlatform { String? nativeAccountId, String? authority, String? claims, + bool forceRefresh = false, }) async => AndroidAuthorizationToken.fromMap( _stringMap( await _methodChannel.invokeMethod('authorizeMicrosoftSilent', { @@ -105,6 +106,7 @@ final class BusyMaxAndroidPlatform { 'nativeAccountId': ?nativeAccountId, 'authority': ?authority, 'claims': ?claims, + 'forceRefresh': forceRefresh, }), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 734bd77a..be79c461 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.3.1 +version: 0.3.2 environment: sdk: ^3.13.4 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 741fc116..47805d57 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.3.1" +version: "0.3.2" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. diff --git a/test/app/caldav_release_metadata_test.dart b/test/app/caldav_release_metadata_test.dart index 82cdec13..76af8bd9 100644 --- a/test/app/caldav_release_metadata_test.dart +++ b/test/app/caldav_release_metadata_test.dart @@ -27,12 +27,12 @@ void main() { RegExp(r' Function()? persistContacts, + bool forceAuthorization = false, }) => throw StateError('authorization is not used by cached lookup'); @override diff --git a/test/contacts/busymax_contacts_store_test.dart b/test/contacts/busymax_contacts_store_test.dart index a941e50e..bc6fd67a 100644 --- a/test/contacts/busymax_contacts_store_test.dart +++ b/test/contacts/busymax_contacts_store_test.dart @@ -32,6 +32,34 @@ void main() { await database.close(); }); + test( + 'orphaned linkage never becomes an independent suggestions source', + () async { + await store.saveLinkedAccount( + account: _account('linked'), + busyMaxAccountId: 'calendar-account', + reuseAuthorization: true, + ); + await store.write((tx) async { + await tx.putSource(_source('linked')); + await tx.putContact(_record('linked', 'one')); + await tx.putAccount(_account('independent')); + await tx.putSource(_source('independent')); + }); + // Reproduce the old ON DELETE SET NULL path, including existing orphans. + await database.customStatement('DELETE FROM accounts WHERE id = ?', [ + 'calendar-account', + ]); + expect(await store.linkedBusyMaxAccount('linked'), isNull); + expect((await store.selection()).accountIds, {'independent'}); + expect((await store.selection(busyMaxAccountId: 'other')).accountIds, { + 'independent', + }); + expect(await store.read((tx) => tx.account('independent')), isNotNull); + expect(await store.read((tx) => tx.count(accountId: 'linked')), 1); + }, + ); + test('data and cursor commit atomically across awaited operations', () async { await store.write((transaction) async { await transaction.putAccount(_account('contacts')); diff --git a/test/contacts/contacts_enrollment_test.dart b/test/contacts/contacts_enrollment_test.dart index bb8f91fc..b3b4e80c 100644 --- a/test/contacts/contacts_enrollment_test.dart +++ b/test/contacts/contacts_enrollment_test.dart @@ -8,6 +8,7 @@ import 'package:busymax/src/contacts/busymax_contacts_controller.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:logging/logging.dart'; import '../support/contacts_fixture.dart'; @@ -250,51 +251,107 @@ void main() { () async { final uncaught = []; final completion = Completer(); - runZonedGuarded(() async { - try { - final f = ContactsFixture( - AppDatabase.memoryForTests(), - syncInterval: const Duration(milliseconds: 20), - ); + const interval = Duration(milliseconds: 20); + _ControlledPeriodicTimer? scheduled; + runZonedGuarded( + () async { try { - await f.addParent(BusyProvider.google); - await f.addParent(BusyProvider.microsoft); - await f.controller.enableLinkedContacts('google'); - await f.controller.enableLinkedContacts('microsoft'); - await f.controller.synchronizeAll(); - f.googleOffline = true; - await Future.delayed(const Duration(milliseconds: 1100)); - expect( - (await f.store.read((tx) => tx.account('contacts:google')))! - .errorCode, - isNotNull, - ); - expect( - (await f.store.read((tx) => tx.account('contacts:microsoft')))! - .errorCode, - isNull, - ); - expect( - await f.controller.suggestAttendees( - 'ada', - busyMaxAccountId: 'microsoft', - ), - hasLength(1), - ); - await expectLater( - f.controller.synchronizeAccount('contacts:google'), - throwsA(isA()), + final f = ContactsFixture( + AppDatabase.memoryForTests(), + syncInterval: interval, ); - } finally { - await f.close(); + try { + await f.addParent(BusyProvider.google); + await f.addParent(BusyProvider.microsoft); + await f.controller.enableLinkedContacts('google'); + await f.controller.enableLinkedContacts('microsoft'); + await f.controller.synchronizeAll(); + final microsoftRequests = f.requests + .where((request) => request.url.host == 'graph.microsoft.com') + .length; + final observedFailure = Logger('busymax.contacts').onRecord + .firstWhere( + (record) => + record.message == 'Scheduled contacts work failed', + ); + f.googleOffline = true; + expect(scheduled?.isActive, isTrue); + scheduled!.fire(); + expect((await observedFailure).error, isA()); + expect( + (await f.store.read((tx) => tx.account('contacts:google')))! + .errorCode, + isNotNull, + ); + expect( + (await f.store.read((tx) => tx.account('contacts:microsoft')))! + .errorCode, + isNull, + ); + expect( + f.requests + .where( + (request) => request.url.host == 'graph.microsoft.com', + ) + .length, + greaterThan(microsoftRequests), + ); + expect( + await f.controller.suggestAttendees( + 'ada', + busyMaxAccountId: 'microsoft', + ), + hasLength(1), + ); + await expectLater( + f.controller.synchronizeAccount('contacts:google'), + throwsA(isA()), + ); + } finally { + await f.close(); + } + completion.complete(); + } catch (e, s) { + completion.completeError(e, s); } - completion.complete(); - } catch (e, s) { - completion.completeError(e, s); - } - }, (e, s) => uncaught.add(e)); - await completion.future.timeout(const Duration(seconds: 5)); + }, + (e, s) => uncaught.add(e), + zoneSpecification: ZoneSpecification( + createPeriodicTimer: (self, parent, zone, duration, callback) { + if (duration != interval) { + return parent.createPeriodicTimer(zone, duration, callback); + } + expect(scheduled, isNull); + return scheduled = _ControlledPeriodicTimer(callback); + }, + ), + ); + await completion.future; expect(uncaught, isEmpty); }, ); } + +/// Drives the real controller's scheduled work without wall-clock races. +final class _ControlledPeriodicTimer implements Timer { + _ControlledPeriodicTimer(this._callback); + + final void Function(Timer) _callback; + bool _active = true; + int _tick = 0; + + void fire() { + if (!_active) return; + _tick++; + _callback(this); + } + + @override + bool get isActive => _active; + + @override + int get tick => _tick; + + @override + void cancel() => _active = false; +} diff --git a/test/contacts/contacts_read_only_state_test.dart b/test/contacts/contacts_read_only_state_test.dart index 27c0ec06..565abe3c 100644 --- a/test/contacts/contacts_read_only_state_test.dart +++ b/test/contacts/contacts_read_only_state_test.dart @@ -12,7 +12,7 @@ import '../support/dav_contacts_fixture.dart'; import 'read_only_contacts_provider_test.dart' show effective; void main() { - test('linked Nextcloud reconnect preserves legacy markers and the parent app password', () async { + test('reenabling linked Nextcloud suggestions preserves legacy markers and the parent app password', () async { final db = AppDatabase.memoryForTests(); final credential = BusyMaxLinkedDavCredential( server: server, @@ -62,7 +62,7 @@ void main() { payload: const {'type': 'historical', 'evidence': 'retain'}, ); await f.store.write((tx) => tx.putMutation(queued)); - await f.controller.reconnectContactsAccount(id); + await f.controller.enableLinkedNextcloudContacts('nextcloud'); await f.controller.synchronizeAccount(id); final account = (await f.store.read((tx) => tx.account(id)))!; expect(account.id, id); diff --git a/test/contacts/parent_removal_lifecycle_test.dart b/test/contacts/parent_removal_lifecycle_test.dart index e86c39e1..9d25987a 100644 --- a/test/contacts/parent_removal_lifecycle_test.dart +++ b/test/contacts/parent_removal_lifecycle_test.dart @@ -275,6 +275,52 @@ class LifecycleFixture { } void main() { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final revoked in [false, true]) { + test( + '$provider contacts retain the authorization classification; revoked=$revoked', + () async { + final f = LifecycleFixture(provider); + addTearDown(f.close); + await f.seed(enabled: true); + final saved = + await f.h.secrets.readCredential(f.id) as BoundOAuthSecretRecord; + await f.h.secrets.saveCredential( + f.id, + saved.withTokens( + saved.tokenSet.copyWith(expiresAtUtc: DateTime.utc(2000)), + ), + ); + f.h.beforeRequest = (_) async { + if (revoked) return http.Response('{"error":"invalid_grant"}', 400); + throw http.ClientException('synthetic offline'); + }; + final classification = revoked ? 'expiredGrant' : 'network'; + await expectLater( + f.controller.synchronizeAccount(f.contactId), + throwsA( + isA().having( + (e) => e.code, + 'safe classification', + 'oauth:$classification', + ), + ), + ); + expect( + (await f.store.read((tx) => tx.account(f.contactId)))!.errorCode, + 'oauth:$classification', + ); + expect( + (await f.h.secrets.readCredential( + f.id, + ) as BoundOAuthSecretRecord).subject, + saved.subject, + ); + }, + ); + } + } + test( 'revoked Google authorization never restores contacts after local rollback', () async { @@ -324,7 +370,7 @@ void main() { tenantId: provider == BusyProvider.microsoft ? tenant : null, grantedScopes: scopes.join(' '), ); - await f.controller.reconnectContactsAccount(f.contactId); + await f.controller.enableLinkedContacts(f.id); await f.controller.synchronizeAccount(f.contactId); await f.seedUncertainMutations(queued: true); await f.h.db.customStatement( diff --git a/test/core/auth/contacts_authorization_requests_test.dart b/test/core/auth/contacts_authorization_requests_test.dart index 48def284..0788a4b7 100644 --- a/test/core/auth/contacts_authorization_requests_test.dart +++ b/test/core/auth/contacts_authorization_requests_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/auth/oauth_registration.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_loopback_flow.dart'; @@ -8,9 +11,157 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; -import 'authorization_transition_test.dart' show Harness, owned, msOwned; +import 'authorization_transition_test.dart' + show Harness, owned, msOwned, fixtureIdToken; void main() { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final recovery in [false, true]) { + test( + '$provider valid contacts grant is reused; forced recovery=$recovery validates the parent', + () async { + final h = Harness(); + addTearDown(() async { + h.staging.dispose(); + await h.db.close(); + }); + final id = provider == BusyProvider.google + ? 'opaque' + : 'microsoft:ms-user'; + if (provider == BusyProvider.google) { + await h.seed(); + await h.makeGoogleUserOwned(); + } else { + await h.seedMicrosoft(shared: false); + } + final before = + await h.secrets.readCredential(id) as BoundOAuthSecretRecord; + final scope = provider == BusyProvider.google + ? googleContactsReadScope + : 'Contacts.Read'; + await h.secrets.saveCredential( + id, + before.withTokens( + before.tokenSet.copyWith( + scopes: {...before.tokenSet.scopes, scope}, + ), + ), + ); + if (recovery) await h.repository.markReconnectRequired(id); + h.beforeRequest = (request) async { + if (request.method != 'POST') return null; + return http.Response( + jsonEncode({ + 'access_token': 'contacts-recovered', + 'refresh_token': 'contacts-refresh', + 'expires_in': 3600, + 'scope': h.flow.lastScope, + if (provider == BusyProvider.microsoft) + 'id_token': fixtureIdToken( + Uri.splitQueryString(request.body)['client_id']!, + ), + }), + 200, + ); + }; + var persisted = 0; + if (provider == BusyProvider.google) { + await h.google.authorizeGoogleContacts( + id, + forceAuthorization: recovery, + persistContacts: () async { + persisted++; + }, + ); + } else { + await h.microsoft.authorizeMicrosoftContacts( + id, + forceAuthorization: recovery, + persistContacts: () async { + persisted++; + }, + ); + } + expect(persisted, 1); + expect(h.flow.clients.length, recovery ? 1 : 0); + expect((await h.accounts.accountById(id))!.isSignedIn, isTrue); + final after = + await h.secrets.readCredential(id) as BoundOAuthSecretRecord; + expect(after.subject, before.subject); + expect(after.registration.clientId, before.registration.clientId); + }, + ); + } + test( + '$provider revoked contacts grant is not accepted from stored scopes', + () async { + final h = Harness(); + addTearDown(() async { + h.staging.dispose(); + await h.db.close(); + }); + final id = provider == BusyProvider.google + ? 'opaque' + : 'microsoft:ms-user'; + if (provider == BusyProvider.google) { + await h.seed(); + await h.makeGoogleUserOwned(); + } else { + await h.seedMicrosoft(shared: false); + } + final original = + await h.secrets.readCredential(id) as BoundOAuthSecretRecord; + final expired = original.withTokens( + original.tokenSet.copyWith( + expiresAtUtc: DateTime.utc(2000), + scopes: { + ...original.tokenSet.scopes, + provider == BusyProvider.google + ? googleContactsReadScope + : 'Contacts.Read', + }, + ), + ); + await h.secrets.saveCredential(id, expired); + h.mode = 'legacy-client-rejected'; + var persisted = false; + final operation = provider == BusyProvider.google + ? h.google.authorizeGoogleContacts( + id, + persistContacts: () async { + persisted = true; + }, + ) + : h.microsoft.authorizeMicrosoftContacts( + id, + persistContacts: () async { + persisted = true; + }, + ); + await expectLater(operation, throwsA(isA())); + expect(persisted, isFalse); + expect( + (await h.secrets.readCredential(id))!.toJson(), + expired.toJson(), + ); + expect(h.flow.clients, isEmpty); + // Reconnect performs authorization even when the old record names contacts scopes. + h.mode = 'cancel'; + final reconnect = provider == BusyProvider.google + ? h.google.authorizeGoogleContacts(id, forceAuthorization: true) + : h.microsoft.authorizeMicrosoftContacts( + id, + forceAuthorization: true, + ); + await expectLater(reconnect, throwsA(isA())); + expect(h.flow.clients, hasLength(1)); + expect( + (await h.secrets.readCredential(id))!.toJson(), + expired.toJson(), + ); + }, + ); + } for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { for (final action in [ 'first-sign-in', diff --git a/test/dav/auth/dav_account_onboarding_service_test.dart b/test/dav/auth/dav_account_onboarding_service_test.dart index 868f1bec..bf7aed4b 100644 --- a/test/dav/auth/dav_account_onboarding_service_test.dart +++ b/test/dav/auth/dav_account_onboarding_service_test.dart @@ -1,19 +1,28 @@ +import 'dart:async'; import 'dart:convert'; import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/core/auth/authorization_persistence.dart'; import 'package:busymax/src/dav/auth/dav_account_onboarding_service.dart'; import 'package:busymax/src/dav/auth/nextcloud_login_flow_v2.dart'; import 'package:busymax/src/dav/dav_errors.dart'; +import 'package:busymax/src/dav/http/dav_http_transport.dart'; import 'package:busymax/src/dav/discovery/dav_discovery_models.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/providers/provider_capabilities.dart'; -import 'package:drift/drift.dart' hide isNull; +import 'package:busymax/src/features/sync/account_sync_operations.dart'; +import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import '../../support/contacts_fixture.dart'; +import '../../support/dav_contacts_fixture.dart' as carddav; + +import 'package:busymax/src/contacts/busymax_contacts_controller.dart'; + void main() { late AppDatabase database; late InMemorySecretStore secrets; @@ -25,6 +34,801 @@ void main() { tearDown(() => database.close()); + test('linked Nextcloud contact retry reuses valid authorization and Reconnect renews the parent before rebinding', () async { + final persistence = AuthorizationPersistence( + database: database, + secrets: secrets, + ); + final service = DavAccountOnboardingService( + database: database, + secretStore: secrets, + authorizationPersistence: persistence, + nextcloudLoginFlow: _successfulLoginFlow( + server: carddav.server.toString(), + rotatePasswords: true, + ), + idFactory: () => 'contacts-parent', + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + ); + final id = (await service.connectNextcloud( + enteredServer: carddav.server.toString(), + )).accountId; + var reconnects = 0; + final fixture = ContactsFixture( + database, + realDav: true, + readAuthorizationGeneration: persistence.generation, + readLinkedDavCredential: (id) async { + final secret = + (await persistence.readCurrentCredential(id))! + as NextcloudSecretRecord; + return BusyMaxLinkedDavCredential( + server: secret.canonicalServer, + username: secret.loginName, + password: secret.appPassword, + ); + }, + reconnectLinkedDavAccount: (id, cancellation) async { + reconnects++; + final token = DavCancellationToken(); + if (cancellation.isCancelled) token.cancel(); + unawaited(cancellation.signal.then((_) => token.cancel())); + await service.reconnectNextcloud( + accountId: id, + enteredServer: carddav.server.toString(), + cancellationToken: token, + ); + cancellation.committed(); + }, + ); + addTearDown(fixture.controller.close); + final dav = carddav.DavFixture(); + fixture.respondDav = dav.respond; + await fixture.controller.enableLinkedNextcloudContacts(id); + final contactId = BusyMaxContactsController.linkedContactAccountId(id); + await fixture.controller.synchronizeAccount(contactId); + final otherServer = carddav.server.replace(host: 'other.example.test'); + await fixture.accounts.upsertSignedInAccount( + id: 'other', + provider: BusyProvider.nextcloud, + authority: otherServer.toString(), + providerAccountId: 'alex', + grantedScopes: '', + ); + final otherCredential = NextcloudSecretRecord( + canonicalServer: otherServer, + loginName: 'alex', + appPassword: 'other-password', + ); + await secrets.saveCredential('other', otherCredential); + await fixture.controller.enableLinkedNextcloudContacts('other'); + await fixture.controller.synchronizeAccount('contacts:other'); + await service.reconnectNextcloud( + accountId: id, + enteredServer: carddav.server.toString(), + ); + final replaced = + await persistence.readCurrentCredential(id) as NextcloudSecretRecord; + fixture.requests.clear(); + await fixture.controller.synchronizeAccount(contactId); + expect(fixture.requests, isNotEmpty); + expect( + fixture.requests.every( + (request) => + request.headers['authorization'] == + 'Basic ${base64Encode(utf8.encode('${replaced.loginName}:${replaced.appPassword}'))}', + ), + isTrue, + ); + final targetRequestCount = fixture.requests.length; + await fixture.controller.synchronizeAccount('contacts:other'); + final otherRequests = fixture.requests.skip(targetRequestCount).toList(); + expect(otherRequests, isNotEmpty); + expect( + otherRequests.every( + (request) => + request.url.host == otherServer.host && + request.headers['authorization'] == + 'Basic ${base64Encode(utf8.encode('alex:other-password'))}', + ), + isTrue, + ); + final source = (await fixture.controller.sourceSettings()) + .firstWhere((setting) => setting.source.accountId == contactId) + .source; + await fixture.store.setSourceEnabled(source.key, enabled: false); + final original = (await secrets.readCredential(id))!.toJson(); + await fixture.controller.synchronizeAccount(contactId); + expect(reconnects, 0); + expect((await secrets.readCredential(id))!.toJson(), original); + await fixture.controller.reconnectContactsAccount(contactId); + await fixture.controller.synchronizeAccount(contactId); + expect(reconnects, 1); + expect((await secrets.readCredential(id))!.toJson(), isNot(original)); + expect(await fixture.store.sourceEnabled(source.key), isFalse); + expect( + (await database.select(database.accounts).get()).map((a) => a.id), + unorderedEquals([id, 'other']), + ); + expect( + (await secrets.readCredential('other'))!.toJson(), + otherCredential.toJson(), + ); + expect( + (await fixture.store.read((tx) => tx.account(contactId)))!.enabled, + isTrue, + ); + expect( + dav.requests.where( + (r) => ['PUT', 'DELETE', 'PROPPATCH', 'MKCOL'].contains(r.method), + ), + isEmpty, + ); + }); + + test('queued cancelled reconnect releases its caller before the account gate and cannot recreate a removed account', () async { + final coordinator = AccountSyncCoordinator(); + addTearDown(coordinator.dispose); + final discovered = Completer(); + var reconnecting = false; + final service = DavAccountOnboardingService( + database: database, + secretStore: secrets, + syncCoordinator: coordinator, + nextcloudLoginFlow: _successfulLoginFlow(rotatePasswords: true), + idFactory: () => 'queued', + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async { + if (reconnecting && !discovered.isCompleted) discovered.complete(); + return _discovery(accountId, provider, accountAuthority); + }, + ); + final id = (await service.connectNextcloud( + enteredServer: 'cloud.example.test', + )).accountId; + final entered = Completer(), release = Completer(); + final blocker = coordinator.run(id, () async { + entered.complete(); + await release.future; + }); + await entered.future; + reconnecting = true; + final token = DavCancellationToken(); + final reconnect = service.reconnectNextcloud( + accountId: id, + enteredServer: 'cloud.example.test', + cancellationToken: token, + ); + final cancelled = expectLater( + reconnect, + throwsA( + isA().having( + (e) => e.kind, + 'kind', + DavErrorKind.cancelled, + ), + ), + ); + await discovered.future; + final removed = service.removeAccount(id); + await cancelled; + expect(release.isCompleted, isFalse); + release.complete(); + await blocker; + await removed; + expect(await database.select(database.accounts).get(), isEmpty); + expect(await secrets.readCredential(id), isNull); + expect(await database.select(database.authorizationCommits).get(), isEmpty); + final next = await service.connectNextcloud( + enteredServer: 'cloud.example.test', + ); + expect(next.accountId, id); + expect((await database.select(database.accounts).getSingle()).id, id); + }); + + test('DAV credential journaling does not call native OAuth hooks and Add cannot replace an existing account', () async { + Never unexpected() => + throw StateError('DAV must not mutate native OAuth bindings'); + final persistence = AuthorizationPersistence( + database: database, + secrets: secrets, + readNativeBinding: (_) async => unexpected(), + restoreNativeBinding: (_, _) async => unexpected(), + onCredentialWritten: (_, _) async => unexpected(), + onRemovalCommitted: (_, _) async => unexpected(), + ); + final service = DavAccountOnboardingService( + database: database, + secretStore: secrets, + authorizationPersistence: persistence, + nextcloudLoginFlow: _successfulLoginFlow(rotatePasswords: true), + idFactory: () => 'dav-only', + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + ); + final id = (await service.connectNextcloud( + enteredServer: 'cloud.example.test', + )).accountId; + final saved = (await secrets.readCredential(id))!.toJson(); + await expectLater( + service.connectNextcloud(enteredServer: 'cloud.example.test'), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'DavAccountAlreadyConnected', + ), + ), + ); + expect((await secrets.readCredential(id))!.toJson(), saved); + await service.reconnectNextcloud( + accountId: id, + enteredServer: 'cloud.example.test', + ); + expect((await secrets.readCredential(id))!.toJson(), isNot(saved)); + await service.removeAccount(id); + expect(await secrets.readCredential(id), isNull); + expect(await database.select(database.accounts).get(), isEmpty); + }); + + for (final state in ['permission_changed', 'unsupported_server_profile']) { + test( + '$state can revalidate without replacing identity, selections or queued work', + () async { + var reject = false; + final service = DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow(), + idFactory: () => 'recover', + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async { + if (reject) { + throw const DavException( + kind: DavErrorKind.authorization, + code: 'DavPermissionDenied', + safeMessage: 'Permissions still need repair.', + ); + } + return _discovery( + accountId, + provider, + accountAuthority, + collections: [_mixedCollection(accountAuthority)], + ); + }, + ); + final id = (await service.connectNextcloud( + enteredServer: 'cloud.example.test', + )).accountId; + await _insertNextcloudLocalState(database, id); + final collection = await database + .select(database.davCollections) + .getSingle(); + await (database.update( + database.davCollections, + )..where((r) => r.id.equals(collection.id))).write( + const DavCollectionsCompanion( + eventsSelected: Value(false), + tasksSelected: Value(true), + ), + ); + await (database.update(database.accounts) + ..where((r) => r.id.equals(id))) + .write(AccountsCompanion(authState: Value(state))); + final beforeSecret = (await secrets.readCredential(id))!.toJson(); + final beforePending = await database + .select(database.pendingOps) + .getSingle(); + reject = true; + await expectLater( + service.revalidateAccount(id), + throwsA(isA()), + ); + expect( + (await database.select(database.accounts).getSingle()).authState, + state, + ); + expect((await secrets.readCredential(id))!.toJson(), beforeSecret); + expect( + await database.select(database.pendingOps).getSingle(), + beforePending, + ); + reject = false; + final result = await service.revalidateAccount(id); + expect(result.accountId, id); + final recovered = await database.select(database.accounts).getSingle(); + expect(recovered.authState, 'signed_in'); + expect(recovered.providerAccountId, 'alex'); + final selected = await database + .select(database.davCollections) + .getSingle(); + expect(selected.id, collection.id); + expect(selected.eventsSelected, isFalse); + expect(selected.tasksSelected, isTrue); + expect( + (await database.select(database.taskLists).get()).any( + (r) => r.id == 'cached-list', + ), + isTrue, + ); + expect( + await database.select(database.pendingOps).getSingle(), + beforePending, + ); + expect((await secrets.readCredential(id))!.toJson(), beforeSecret); + }, + ); + } + + test('same Nextcloud username on different servers remains isolated during reconnect and removal', () async { + DavAccountOnboardingService service(String server, String id) => + DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow(server: server), + idFactory: () => id, + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + ); + final firstService = service('https://cloud.example.test', 'one'); + final secondService = service('https://other.example.test', 'two'); + final first = await firstService.connectNextcloud( + enteredServer: 'cloud.example.test', + ); + final second = await secondService.connectNextcloud( + enteredServer: 'other.example.test', + ); + expect(first.accountId, isNot(second.accountId)); + final secondBefore = (await secrets.readCredential(second.accountId))! + .toJson(); + await firstService.reconnectNextcloud( + accountId: first.accountId, + enteredServer: 'cloud.example.test', + ); + expect(await database.select(database.accounts).get(), hasLength(2)); + await firstService.removeAccount(first.accountId); + expect( + (await database.select(database.accounts).getSingle()).id, + second.accountId, + ); + expect( + (await secrets.readCredential(second.accountId))!.toJson(), + secondBefore, + ); + expect(await secrets.readActiveAccountId(), isNull); + }); + for (final failLocal in [false, true]) { + test( + 'Nextcloud coordinated removal drains contacts and isolates other accounts; local failure=$failLocal', + () async { + final gate = _ObservedAuthorizationGate(); + final coordinator = AccountSyncCoordinator(); + addTearDown(coordinator.dispose); + final persistence = AuthorizationPersistence( + database: database, + secrets: secrets, + gate: gate, + ); + final fixture = ContactsFixture( + database, + realDav: true, + // The production reader, including the shared authorization lock. + readLinkedDavCredential: (id) async { + final credential = await persistence.readCurrentCredential(id); + return credential is NextcloudSecretRecord + ? BusyMaxLinkedDavCredential( + server: credential.canonicalServer, + username: credential.loginName, + password: credential.appPassword, + ) + : null; + }, + readAuthorizationGeneration: persistence.generation, + ); + addTearDown(fixture.controller.close); + final remote = carddav.DavFixture(); + fixture.respondDav = remote.respond; + var revoked = 0; + var restored = false; + var restoredWithAccountGate = false; + final restoring = Completer(), + releaseRestoration = Completer(); + addTearDown(() { + if (!releaseRestoration.isCompleted) releaseRestoration.complete(); + }); + final service = DavAccountOnboardingService( + database: database, + secretStore: secrets, + authorizationPersistence: persistence, + accountGate: gate, + syncCoordinator: coordinator, + nextcloudLoginFlow: _successfulLoginFlow( + server: carddav.server.toString(), + rotatePasswords: true, + ), + idFactory: () => 'parent', + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + onAccountRemovalStarting: + fixture.controller.beginLinkedAccountRemoval, + onAccountRemoving: fixture.controller.prepareLinkedAccountRemoval, + persistLinkedAccountRemoval: + fixture.controller.persistLinkedAccountRemoval, + onAccountRemovalFailed: (id) async { + // Fail deterministically instead of hanging the test on recursive + // entry into the production non-reentrant credential reader. + if (gate.held.contains('authorization:$id')) { + throw StateError('Contacts restoration owns authorization lock'); + } + restoredWithAccountGate = gate.held.contains(id); + restoring.complete(); + await releaseRestoration.future; + await fixture.controller.restoreLinkedAccountAfterFailedRemoval(id); + restored = true; + }, + onAccountRemoved: fixture.controller.linkedAccountRemovalCommitted, + onAccountRemovalFinished: + fixture.controller.finishLinkedAccountRemoval, + nextcloudCredentialRevoker: + ({required accountId, required credential}) async { + revoked++; + }, + ); + final parent = (await service.connectNextcloud( + enteredServer: 'cloud.example.test', + )).accountId; + await fixture.accounts.upsertSignedInAccount( + id: 'unrelated', + provider: BusyProvider.nextcloud, + authority: 'https://other.example.test', + providerAccountId: 'alex', + grantedScopes: '', + ); + await secrets.saveCredential( + 'unrelated', + NextcloudSecretRecord( + canonicalServer: Uri.parse('https://other.example.test'), + loginName: 'alex', + appPassword: 'other-synthetic', + ), + ); + await fixture.controller.enableLinkedNextcloudContacts(parent); + final id = 'contacts:$parent'; + await fixture.controller.synchronizeAccount(id); + await fixture.controller.addCardDavContactsOnly( + id: 'independent', + label: 'Independent QA', + server: carddav.server, + username: 'independent', + password: 'synthetic', + ); + expect( + await fixture.store.read((tx) => tx.count(accountId: id)), + greaterThan(0), + ); + final independentSources = await fixture.store.read( + (tx) => tx.sources(accountId: 'independent'), + ); + await fixture.store.setSourceEnabled( + independentSources.first.key, + enabled: false, + ); + if (failLocal) { + await database.customStatement( + "CREATE TRIGGER reject_parent_removal BEFORE DELETE ON accounts WHEN OLD.id = '$parent' BEGIN SELECT RAISE(ABORT, 'fixture rollback'); END", + ); + final removal = service.removeAccount(parent); + final rejected = expectLater(removal, throwsA(isA())); + await Future.any([ + restoring.future, + removal.then((_) {}, onError: (Object _, StackTrace _) {}), + ]); + expect(restoring.isCompleted, isTrue); + final previous = (await persistence.readCurrentCredential(parent))! + .toJson(); + final discovered = Completer(); + final replacementService = DavAccountOnboardingService( + database: database, + secretStore: secrets, + authorizationPersistence: persistence, + syncCoordinator: coordinator, + nextcloudLoginFlow: _successfulLoginFlow( + server: carddav.server.toString(), + appPassword: 'concurrent-password', + ), + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async { + discovered.complete(); + return _discovery(accountId, provider, accountAuthority); + }, + ); + final replacing = replacementService.reconnectNextcloud( + accountId: parent, + enteredServer: carddav.server.toString(), + ); + await discovered.future; + // Recovery can read authorization, while replacement still waits + // on the production account coordinator until restoration finishes. + expect( + (await persistence.readCurrentCredential(parent))!.toJson(), + previous, + ); + releaseRestoration.complete(); + await rejected; + await replacing; + expect( + (await persistence.readCurrentCredential( + parent, + ) as NextcloudSecretRecord).appPassword, + 'concurrent-password', + ); + expect(restored, isTrue); + expect(restoredWithAccountGate, isTrue); + expect(revoked, 0); + expect( + (await fixture.store.read((tx) => tx.account(id)))!.enabled, + isTrue, + ); + expect( + await secrets.readCredential(parent), + isA(), + ); + await fixture.controller.synchronizeAccount(id); + await database.customStatement('DROP TRIGGER reject_parent_removal'); + final beforeReconnect = (await secrets.readCredential(parent))! + .toJson(); + await service.reconnectNextcloud( + accountId: parent, + enteredServer: carddav.server.toString(), + ); + expect( + (await secrets.readCredential(parent))!.toJson(), + isNot(beforeReconnect), + ); + } + final entered = Completer(), release = Completer(); + var block = true; + fixture.respondDav = (request) async { + if (block && request.method == 'REPORT') { + block = false; + entered.complete(); + await release.future; + } + return remote.respond(request); + }; + final lateSync = fixture.controller.synchronizeAccount(id); + final observed = lateSync.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + await entered.future; + final removal = service.removeAccount(parent); + release.complete(); + await removal; + await observed; + expect(await fixture.store.read((tx) => tx.account(id)), isNull); + expect(await fixture.store.read((tx) => tx.count(accountId: id)), 0); + expect(await fixture.store.linkedContactAccounts(parent), isEmpty); + expect( + (await fixture.store.selection()).accountIds, + isNot(contains(id)), + ); + expect(await fixture.accounts.accountById('unrelated'), isNotNull); + expect( + await secrets.readCredential('unrelated'), + isA(), + ); + expect( + await fixture.store.read((tx) => tx.account('independent')), + isNotNull, + ); + expect( + await fixture.store.sourceEnabled(independentSources.first.key), + isFalse, + ); + expect(revoked, 1); + }, + ); + } + + for (final phase in ['discovery', 'save', 'readback', 'commit']) { + test( + 'cancel during $phase restores the previous credential and leaves no commit', + () async { + final entered = Completer(), release = Completer(); + final gated = _GatedSecrets(entered, release); + _CommitGate? commitGate; + if (phase == 'commit') { + await database.close(); + commitGate = _CommitGate(entered, release); + database = AppDatabase( + NativeDatabase.memory().interceptWith(commitGate), + ); + } + final token = DavCancellationToken(); + var block = false; + final service = DavAccountOnboardingService( + database: database, + secretStore: gated, + nextcloudLoginFlow: _successfulLoginFlow(rotatePasswords: true), + idFactory: () => 'cancelled', + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async { + if (block && phase == 'discovery') { + entered.complete(); + await release.future; + } + return _discovery(accountId, provider, accountAuthority); + }, + ); + final initial = await service.connectNextcloud( + enteredServer: 'cloud.example.test', + ); + final previous = await gated.readCredential(initial.accountId); + block = true; + gated.phase = phase; + gated.wrote = false; + if (commitGate != null) commitGate.enabled = true; + // The same flow fixture can be reused after the first operation. + final operation = service.reconnectNextcloud( + accountId: initial.accountId, + enteredServer: 'cloud.example.test', + cancellationToken: token, + ); + final failure = expectLater( + operation, + throwsA( + isA().having( + (e) => e.kind, + 'kind', + DavErrorKind.cancelled, + ), + ), + ); + await entered.future; + token.cancel(); + release.complete(); + await failure; + expect( + (await gated.readCredential( + initial.accountId, + ) as NextcloudSecretRecord).appPassword, + (previous as NextcloudSecretRecord).appPassword, + ); + expect(await database.select(database.accounts).get(), hasLength(1)); + }, + ); + } + + test('cancellation after the SQLite commit returns success and preserves the accepted credential', () async { + await database.close(); + final token = DavCancellationToken(); + final gate = _CommitGate(Completer(), Completer()) + ..afterCommit = token.cancel; + database = AppDatabase(NativeDatabase.memory().interceptWith(gate)); + final service = DavAccountOnboardingService( + database: database, + secretStore: secrets, + nextcloudLoginFlow: _successfulLoginFlow(), + idFactory: () => 'committed', + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + ); + gate.enabled = true; + final result = await service.connectNextcloud( + enteredServer: 'cloud.example.test', + cancellationToken: token, + ); + expect(token.isCancelled, isTrue); + expect( + (await database.select(database.accounts).getSingle()).id, + result.accountId, + ); + expect( + await secrets.readCredential(result.accountId), + isA(), + ); + expect(await database.select(database.authorizationCommits).get(), isEmpty); + }); + + test('cancelled new credential persistence leaves no account and permits the next attempt', () async { + final entered = Completer(), release = Completer(); + final gated = _GatedSecrets(entered, release)..phase = 'save'; + final service = DavAccountOnboardingService( + database: database, + secretStore: gated, + nextcloudLoginFlow: _successfulLoginFlow(), + idFactory: () => 'new', + discover: ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => _discovery(accountId, provider, accountAuthority), + ); + final token = DavCancellationToken(); + final operation = service.connectNextcloud( + enteredServer: 'cloud.example.test', + cancellationToken: token, + ); + final cancelled = expectLater( + operation, + throwsA( + isA().having( + (e) => e.kind, + 'kind', + DavErrorKind.cancelled, + ), + ), + ); + await entered.future; + token.cancel(); + release.complete(); + await cancelled; + expect(await database.select(database.accounts).get(), isEmpty); + expect(await gated.readCredential('nextcloud:new'), isNull); + expect(await database.select(database.authorizationCommits).get(), isEmpty); + final connected = await service.connectNextcloud( + enteredServer: 'cloud.example.test', + ); + expect( + (await database.select(database.accounts).getSingle()).id, + connected.accountId, + ); + }); + test( 'Apple discovery completes before trimmed opaque secret is stored', () async { @@ -562,19 +1366,24 @@ NextcloudLoginFlowV2 _successfulLoginFlow({ String server = 'https://cloud.example.test/', String loginName = 'alex', String appPassword = 'app-password', + bool rotatePasswords = false, }) { var call = 0; return NextcloudLoginFlowV2( - client: MockClient((_) async { + client: MockClient((request) async { call += 1; - return call == 1 + return call.isOdd ? http.Response( jsonEncode({ 'poll': { 'token': 'token', - 'endpoint': 'https://cloud.example.test/login/v2/poll', + 'endpoint': request.url + .resolve('../../login/v2/poll') + .toString(), }, - 'login': 'https://cloud.example.test/login/v2/flow/browser', + 'login': request.url + .resolve('../../login/v2/flow/browser') + .toString(), }), 200, ) @@ -582,7 +1391,9 @@ NextcloudLoginFlowV2 _successfulLoginFlow({ jsonEncode({ 'server': server, 'loginName': loginName, - 'appPassword': appPassword, + 'appPassword': rotatePasswords + ? '$appPassword-$call' + : appPassword, }), 200, ); @@ -592,6 +1403,52 @@ NextcloudLoginFlowV2 _successfulLoginFlow({ ); } +/// Observes production gate calls without changing their behavior. The shared +/// AccountSyncCoordinator and AuthorizationPersistence retain serialization. +final class _ObservedAuthorizationGate implements CrossEngineAccountGate { + final _gate = const InProcessAccountGate(); + final held = {}; + + @override + Future run(String id, Future Function() work) => + _gate.run(id, () async { + held.add(id); + try { + return await work(); + } finally { + held.remove(id); + } + }); +} + +class _GatedSecrets extends InMemorySecretStore { + _GatedSecrets(this.entered, this.release); + final Completer entered, release; + String? phase; + bool wrote = false; + @override + Future saveCredential(String id, SecretRecord record) async { + await super.saveCredential(id, record); + wrote = true; + if (phase == 'save' && id.startsWith('nextcloud:')) { + phase = null; + entered.complete(); + await release.future; + } + } + + @override + Future readCredential(String id) async { + final value = await super.readCredential(id); + if (phase == 'readback' && wrote && id.startsWith('nextcloud:')) { + phase = null; + entered.complete(); + await release.future; + } + return value; + } +} + Future _insertNextcloudLocalState( AppDatabase database, String accountId, @@ -644,8 +1501,9 @@ Future _markPendingAuthenticationBlocked(AppDatabase database) async { DavDiscoveryResult _discovery( String accountId, BusyProvider provider, - Uri authority, -) => DavDiscoveryResult( + Uri authority, { + List collections = const [], +}) => DavDiscoveryResult( accountId: accountId, provider: provider, service: DavServiceDiscovery( @@ -664,5 +1522,106 @@ DavDiscoveryResult _discovery( lastValidatedAtUtc: DateTime.utc(2026, 8, 8, 12), providerProfileVersion: 1, ), - collections: const [], + collections: collections, ); + +DavCollectionDiscovery _mixedCollection(Uri authority) => + DavCollectionDiscovery( + hrefKey: '/calendars/user/mixed/', + requestUri: authority.resolve('/calendars/user/mixed/'), + displayName: 'Mixed QA', + description: null, + resourceTypes: const { + '{DAV:}collection', + '{urn:ietf:params:xml:ns:caldav}calendar', + }, + supportedComponentMask: 3, + supportedCalendarData: const [], + supportedReports: const {}, + currentUserPrivileges: const { + '{DAV:}read', + '{DAV:}write', + '{DAV:}write-properties', + }, + ownerHref: null, + safeDisplayMetadata: const {}, + color: null, + sortOrder: null, + calendarTimeZone: null, + calendarTimeZoneId: null, + scheduleTransparency: null, + maximumResourceSize: null, + maximumInstances: null, + syncToken: null, + ctag: null, + capabilities: const CollectionCapabilities( + canRead: true, + canWriteContent: true, + canWriteProperties: true, + canAddMembers: true, + canDeleteMembers: true, + supportsEvents: true, + supportsTasks: true, + ), + kind: DavCollectionKind.mixedCalendar, + eventProjectionEnabled: true, + taskProjectionEnabled: true, + ); + +class _CommitGate extends QueryInterceptor { + _CommitGate(this.entered, this.release); + final Completer entered, release; + bool enabled = false; + void Function()? afterCommit; + QueryExecutor? accountTransaction; + @override + Future runInsert( + QueryExecutor executor, + String statement, + List args, + ) async { + final result = await super.runInsert(executor, statement, args); + if (enabled && statement.startsWith('INSERT INTO "accounts"')) { + accountTransaction ??= executor; + } + if (enabled && + afterCommit == null && + statement.startsWith('INSERT INTO "accounts"')) { + enabled = false; + entered.complete(); + await release.future; + } + return result; + } + + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) async { + final result = await super.runUpdate(executor, statement, args); + if (enabled && statement.startsWith('UPDATE "accounts"')) { + accountTransaction ??= executor; + } + if (enabled && + afterCommit == null && + statement.startsWith('UPDATE "accounts"')) { + enabled = false; + entered.complete(); + await release.future; + } + return result; + } + + @override + Future commitTransaction(TransactionExecutor inner) async { + await super.commitTransaction(inner); + if (enabled && + afterCommit != null && + identical(accountTransaction, inner)) { + enabled = false; + afterCommit!(); + } + } +} diff --git a/test/dav/auth/nextcloud_login_flow_v2_test.dart b/test/dav/auth/nextcloud_login_flow_v2_test.dart index df148391..156813c4 100644 --- a/test/dav/auth/nextcloud_login_flow_v2_test.dart +++ b/test/dav/auth/nextcloud_login_flow_v2_test.dart @@ -1,12 +1,92 @@ +import 'dart:async'; import 'dart:convert'; import 'package:busymax/src/dav/auth/nextcloud_login_flow_v2.dart'; import 'package:busymax/src/dav/dav_errors.dart'; +import 'package:busymax/src/dav/http/dav_http_transport.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + for (final phase in ['browser', 'poll', 'delay']) { + test( + 'external cancellation releases blocked $phase and isolates the next attempt', + () async { + final entered = Completer(), release = Completer(); + var block = true, polls = 0; + final flow = NextcloudLoginFlowV2( + client: MockClient((request) async { + if (request.url.path.endsWith('/index.php/login/v2')) { + return http.Response( + jsonEncode({ + 'poll': { + 'token': 'synthetic', + 'endpoint': 'https://cloud.example.test/poll', + }, + 'login': 'https://cloud.example.test/browser', + }), + 200, + ); + } + polls++; + if (block && phase == 'poll') { + entered.complete(); + await release.future; + } + if (block && phase == 'delay') return http.Response('', 404); + return http.Response( + jsonEncode({ + 'server': 'https://cloud.example.test', + 'loginName': 'qa', + 'appPassword': 'synthetic', + }), + 200, + ); + }), + browserLauncher: (_) async { + if (block && phase == 'browser') { + entered.complete(); + await release.future; + } + return true; + }, + delay: (_) async { + entered.complete(); + await release.future; + }, + ); + final oldToken = DavCancellationToken(); + final first = flow.start( + 'cloud.example.test', + cancellationToken: oldToken, + ); + final cancelled = expectLater( + first, + throwsA( + isA().having( + (e) => e.kind, + 'kind', + DavErrorKind.cancelled, + ), + ), + ); + await entered.future; + oldToken.cancel(); + // The first request is still blocked: cancellation must release its guard. + await cancelled; + block = false; + final second = await flow.start('cloud.example.test'); + expect(second.loginName, 'qa'); + final completedPolls = polls; + release.complete(); + // Drain the deliberately late callback; it must not poll or clear new work. + await Future.value(); + await Future.value(); + expect(polls, completedPolls); + }, + ); + } test('normalizes HTTPS while preserving an installation path', () { expect( normalizeNextcloudLoginServer(' cloud.example.test/nextcloud/ '), diff --git a/test/dav/ical/ical_recurrence_test.dart b/test/dav/ical/ical_recurrence_test.dart index a5d4ed0d..0253ce80 100644 --- a/test/dav/ical/ical_recurrence_test.dart +++ b/test/dav/ical/ical_recurrence_test.dart @@ -8,6 +8,276 @@ import 'package:busymax/src/ical/ical_event_projection.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('narrow ranges include candidates before the period anchor', () { + for (final example in [ + ( + '20260126T090000Z', + 'FREQ=MONTHLY;BYDAY=-1MO', + DateTime.utc(2026, 2), + DateTime.utc(2026, 2, 24), + '20260223T090000Z', + ), + ( + '20260101T090000Z', + 'FREQ=DAILY;BYHOUR=6,9', + DateTime.utc(2026, 1, 2), + DateTime.utc(2026, 1, 2, 7), + '20260102T060000Z', + ), + ( + '20260130T090000Z', + 'FREQ=WEEKLY;BYDAY=MO,FR;WKST=MO', + DateTime.utc(2026, 2, 2), + DateTime.utc(2026, 2, 3), + '20260202T090000Z', + ), + ( + '20260101T094500Z', + 'FREQ=HOURLY;BYMINUTE=5,45', + DateTime.utc(2026, 1, 1, 10), + DateTime.utc(2026, 1, 1, 10, 10), + '20260101T100500Z', + ), + ( + '20260101T090045Z', + 'FREQ=MINUTELY;BYSECOND=5,45', + DateTime.utc(2026, 1, 1, 9, 1), + DateTime.utc(2026, 1, 1, 9, 1, 10), + '20260101T090105Z', + ), + ( + '20260126T090000Z', + 'FREQ=MONTHLY;BYMONTHDAY=1,26', + DateTime.utc(2026, 2), + DateTime.utc(2026, 2, 2), + '20260201T090000Z', + ), + ( + '20260223T090000Z', + 'FREQ=YEARLY;BYMONTH=2;BYDAY=-1MO', + DateTime.utc(2027, 2), + DateTime.utc(2027, 2, 23), + '20270222T090000Z', + ), + ]) { + final document = IcalSemanticDocument.parse( + _eventWithRule( + start: 'DTSTART:${example.$1}', + end: 'DURATION:PT1H', + rule: 'RRULE:${example.$2}', + ), + ); + final expander = IcalRecurrenceExpander(); + final narrow = expander.expand( + document, + rangeStartUtc: example.$3, + rangeEndUtc: example.$4, + ); + final wide = expander.expand( + document, + rangeStartUtc: example.$3, + rangeEndUtc: example.$4.add( + example.$2.startsWith('FREQ=HOURLY') || + example.$2.startsWith('FREQ=MINUTELY') + ? const Duration(hours: 2) + : const Duration(days: 40), + ), + ); + expect( + narrow.map((item) => item.start.rawValue), + contains(example.$5), + reason: example.$2, + ); + expect( + narrow.map((item) => item.occurrenceKey), + wide + .where((item) => icalTemporalToUtc(item.start).isBefore(example.$4)) + .map((item) => item.occurrenceKey), + reason: example.$2, + ); + } + }); + + test('yearly month-day selectors expand months without changing anniversary defaults', () { + for (final example in [ + ( + 'FREQ=YEARLY;BYMONTHDAY=1', + [for (var month = 1; month <= 12; month++) DateTime.utc(2026, month)], + '20260101T000000Z', + ), + ( + 'FREQ=YEARLY;BYMONTHDAY=-1', + [ + for (var month = 1; month <= 12; month++) + DateTime.utc(2026, month + 1, 0), + ], + '20260131T000000Z', + ), + ( + 'FREQ=YEARLY;BYMONTH=2,4;BYMONTHDAY=1,-1', + [ + DateTime.utc(2026, 2), + DateTime.utc(2026, 2, 28), + DateTime.utc(2026, 4), + DateTime.utc(2026, 4, 30), + ], + '20260201T000000Z', + ), + ('FREQ=YEARLY', [DateTime.utc(2026)], '20260101T000000Z'), + ( + 'FREQ=YEARLY;BYMONTHDAY=1;BYSETPOS=-1', + [DateTime.utc(2026, 12)], + '20261201T000000Z', + ), + ]) { + final document = IcalSemanticDocument.parse( + _eventWithRule( + start: 'DTSTART:${example.$3}', + end: 'DURATION:PT1H', + rule: 'RRULE:${example.$1}', + ), + ); + expect( + IcalRecurrenceExpander() + .expand( + document, + rangeStartUtc: DateTime.utc(2026), + rangeEndUtc: DateTime.utc(2027), + ) + .map((item) => icalTemporalToUtc(item.start)), + example.$2, + reason: example.$1, + ); + } + }); + + for (final period in ['20260102T120000Z', 'PT3H']) { + test('period RDATE preserves its end and overlap: $period', () { + final document = IcalSemanticDocument.parse( + _eventWithRule( + start: 'DTSTART:20260101T090000Z', + end: 'DURATION:PT1H', + rule: 'RDATE;VALUE=PERIOD:20260102T090000Z/$period', + ), + ); + final occurrences = IcalRecurrenceExpander().expand( + document, + rangeStartUtc: DateTime.utc(2026, 1, 2, 11), + rangeEndUtc: DateTime.utc(2026, 1, 2, 13), + ); + expect(occurrences, hasLength(1)); + expect(occurrences.single.end?.rawValue, '20260102T120000Z'); + final projected = IcalEventProjector().projectSemantic( + document, + rangeStartUtc: DateTime.utc(2026, 1, 2, 11), + rangeEndUtc: DateTime.utc(2026, 1, 2, 13), + transport: 'caldav', + ); + expect( + jsonDecode(projected.single.rawJson)['endUtc'], + '2026-01-02T12:00:00.000Z', + ); + }); + } + + test( + 'yearly month-day expansion still intersects other yearly selectors', + () { + for (final example in [ + ( + '20260501T000000Z', + 'FREQ=YEARLY;BYMONTHDAY=1,-1;BYDAY=MO,FR', + [ + DateTime.utc(2026, 5), + DateTime.utc(2026, 6), + DateTime.utc(2026, 7, 31), + DateTime.utc(2026, 8, 31), + DateTime.utc(2026, 11, 30), + ], + ), + ( + '20260101T000000Z', + 'FREQ=YEARLY;BYMONTHDAY=1;BYYEARDAY=1,32,60', + [DateTime.utc(2026), DateTime.utc(2026, 2), DateTime.utc(2026, 3)], + ), + ( + '20260101T000000Z', + 'FREQ=YEARLY;BYMONTHDAY=1;BYWEEKNO=1', + [DateTime.utc(2026)], + ), + ]) { + final occurrences = IcalRecurrenceExpander().expand( + IcalSemanticDocument.parse( + _eventWithRule( + start: 'DTSTART:${example.$1}', + end: 'DURATION:PT1H', + rule: 'RRULE:${example.$2}', + ), + ), + rangeStartUtc: DateTime.utc(2026), + rangeEndUtc: DateTime.utc(2027), + ); + expect( + occurrences.map((occurrence) => icalTemporalToUtc(occurrence.start)), + example.$3, + reason: example.$2, + ); + } + }, + ); + + for (final excluded in [false, true]) { + for (final detached in [false, true]) { + test( + 'period RDATE duration wins a duplicate start, respects exclusions and detached ends ($excluded, $detached)', + () { + final document = IcalSemanticDocument.parse( + [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax Test//EN', + 'BEGIN:VEVENT', + 'UID:period-identity', + 'DTSTART:20260101T090000Z', + 'DURATION:PT1H', + 'RRULE:FREQ=DAILY;COUNT=2', + 'RDATE;VALUE=PERIOD:20260102T090000Z/PT3H', + 'RDATE:20260102T090000Z', + if (excluded) 'EXDATE:20260102T090000Z', + 'END:VEVENT', + if (detached) ...[ + 'BEGIN:VEVENT', + 'UID:period-identity', + 'RECURRENCE-ID:20260102T090000Z', + 'DTSTART:20260102T100000Z', + 'DTEND:20260102T140000Z', + 'END:VEVENT', + ], + 'END:VCALENDAR', + '', + ].join('\r\n'), + ); + final occurrences = IcalRecurrenceExpander().expand( + document, + rangeStartUtc: DateTime.utc(2026, 1, 2), + rangeEndUtc: DateTime.utc(2026, 1, 3), + ); + expect(occurrences, hasLength(excluded ? 0 : 1)); + if (!excluded) { + expect( + occurrences.single.recurrenceId.rawValue, + '20260102T090000Z', + ); + expect( + occurrences.single.end!.rawValue, + detached ? '20260102T140000Z' : '20260102T120000Z', + ); + } + }, + ); + } + } + test('expands weekly recurrence with RDATE, EXDATE, and moved exception', () { final document = IcalSemanticDocument.parse('''BEGIN:VCALENDAR\r VERSION:2.0\r diff --git a/test/dav/mutation/dav_repository_mutation_integration_test.dart b/test/dav/mutation/dav_repository_mutation_integration_test.dart index bfe68d02..6dd1e419 100644 --- a/test/dav/mutation/dav_repository_mutation_integration_test.dart +++ b/test/dav/mutation/dav_repository_mutation_integration_test.dart @@ -1,8 +1,13 @@ import 'dart:convert'; +import 'dart:io'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/dav/dav_errors.dart'; +import 'package:busymax/src/dav/dav_provider_profile.dart'; +import 'package:busymax/src/dav/http/dav_http_transport.dart'; +import 'package:busymax/src/dav/mutation/dav_conditional_mutation_service.dart'; +import 'package:busymax/src/dav/mutation/dav_pending_operations.dart'; import 'package:busymax/src/dav/ical/ical_semantics.dart'; import 'package:busymax/src/dav/ical/ical_recurrence.dart'; import 'package:busymax/src/dav/ical/ical_document.dart'; @@ -13,17 +18,27 @@ import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/calendar/domain/event_timing_policy.dart'; +import 'package:busymax/src/features/recurrence/domain/series_timing_change.dart'; import 'package:busymax/src/features/maps/domain/geographic_point.dart'; import 'package:busymax/src/features/maps/domain/location_result.dart'; import 'package:busymax/src/features/maps/data/location_resolution_repository.dart'; import 'package:busymax/src/core/time/provider_date_time.dart'; +import 'package:busymax/src/core/time/stored_temporal_projection.dart'; import 'package:busymax/src/schedule/schedule_event_rescheduling.dart'; import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart' hide isNotNull, isNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import '../../support/persistence_test_directory.dart'; void main() { late AppDatabase database; @@ -31,15 +46,14 @@ void main() { late CalendarRepository calendarRepository; late TasksRepository tasksRepository; late int notificationChanges; + Directory? persistenceDirectory; + late int objectSequence; - setUp(() async { - database = AppDatabase(NativeDatabase.memory()); - var objectSequence = 0; + void attachRepositories() { objectRepository = DavObjectRepository( database: database, idFactory: () => 'dav-object-${objectSequence += 1}', ); - notificationChanges = 0; calendarRepository = CalendarRepository( database: database, now: () => _now, @@ -52,10 +66,346 @@ void main() { nowUtc: () => _now, onNotificationScheduleChanged: () async => notificationChanges += 1, ); + } + + Future reopen() async { + await database.close(); + database = AppDatabase( + NativeDatabase(File('${persistenceDirectory!.path}/busymax.sqlite')), + ); + attachRepositories(); + } + + Future usePersistentFixture() async { + persistenceDirectory = await createPersistenceTestDirectory( + 'busymax-dav-lifecycle-', + ); + await reopen(); + await _seedDavAccount(database); + } + + Future replayResource(String candidate) async { + var puts = 0; + final transport = DavHttpTransport( + client: MockClient((request) async { + if (request.method == 'PUT') { + expect(request.headers['if-match'], '"one"'); + expect(request.body, candidate); + puts++; + return http.Response('', 204, headers: {'etag': '"returned"'}); + } + expect(request.method, 'GET'); + return http.Response( + candidate, + 200, + headers: {'etag': '"returned"', 'content-type': 'text/calendar'}, + ); + }), + profile: davProviderProfile( + BusyProvider.nextcloud, + nextcloudServer: Uri.parse('https://cloud.example.test'), + ), + accountAuthority: Uri.parse('https://cloud.example.test'), + ); + final result = await DavPendingOperationsReplayer( + database: database, + accountId: _accountId, + objectRepository: objectRepository, + nowUtc: () => _now, + serviceFactory: ({required account, required collection}) async => + DavConditionalMutationService( + remoteClient: DavMutationHttpClient( + transport: transport, + accountId: _accountId, + collectionId: _collectionId, + credential: DavBasicCredential( + username: 'alex', + password: 'fixture-password', + ), + ), + nowUtc: () => _now, + ), + ).replayDueOperations(); + expect(result.appliedCount, 1); + expect(result.conflictCount, 0); + expect(puts, 1); + expect(await database.select(database.pendingOps).get(), isEmpty); + } + + setUp(() async { + persistenceDirectory = null; + objectSequence = 0; + notificationChanges = 0; + database = AppDatabase(NativeDatabase.memory()); + attachRepositories(); await _seedDavAccount(database); }); - tearDown(() => database.close()); + tearDown(() async { + await database.close(); + await persistenceDirectory?.delete(recursive: true); + }); + + for (final deadline in [false, true]) { + test( + 'pending DAV task retains explicit deadline eligibility ($deadline)', + () async { + await usePersistentFixture(); + await tasksRepository.createTask( + _taskListId, + TaskCreateInput( + title: 'Pending task', + fields: { + 'microsoftStartDateTime': '2026-08-10', + if (deadline) 'microsoftDueDateTime': '2026-08-11', + }, + ), + ); + await reopen(); + final task = await database.select(database.tasks).getSingle(); + expect( + taskDeadlineAsLocal(task), + deadline ? DateTime(2026, 8, 11) : isNull, + ); + expect(await database.select(database.pendingOps).get(), hasLength(1)); + if (deadline) { + await tasksRepository.patchTask( + _taskListId, + task.id, + const TaskPatchInput({'microsoftDueDateTime': null}), + ); + await reopen(); + final cleared = await database.select(database.tasks).getSingle(); + expect(taskDeadlineAsLocal(cleared), isNull); + expect(taskDueAsLocal(cleared), DateTime(2026, 8, 10)); + } + }, + ); + } + + test('pending DAV recurring completion advances the active projection before replay', () async { + await usePersistentFixture(); + await tasksRepository.createTask( + _taskListId, + const TaskCreateInput( + title: 'Pending recurring', + fields: { + 'microsoftDueDateTime': '2026-08-10', + 'recurrence': { + 'rules': ['FREQ=DAILY;COUNT=2'], + 'dates': [], + 'excludedDates': [], + }, + }, + ), + ); + final task = await database.select(database.tasks).getSingle(); + await tasksRepository.patchTask( + _taskListId, + task.id, + const TaskPatchInput({'status': 'completed'}), + ); + await reopen(); + final next = await database.select(database.tasks).getSingle(); + expect(next.status, 'needsAction'); + expect(taskDeadlineAsLocal(next), DateTime(2026, 8, 11)); + expect(await database.select(database.pendingOps).get(), hasLength(1)); + }); + + for (final following in [false, true]) { + test( + 'cleared ${following ? 'following' : 'detached'} occurrence alarms stay cleared through resource projection', + () async { + await usePersistentFixture(); + final baseline = _recurringEventResource().replaceFirst( + 'X-SERIES-KEEP:opaque', + 'X-SERIES-KEEP:opaque\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT10M\r\nDESCRIPTION:Reminder\r\nEND:VALARM', + ); + const href = '${_collectionHref}alarms.ics'; + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"one"', body: baseline), + ]); + final occurrence = + (await database.select(database.calendarEvents).get()).singleWhere( + (row) => row.occurrenceKey!.contains('2026-08-10T09:00:00'), + ); + await calendarRepository.updateLocalEvent( + _draftForEvent(occurrence).copyWith( + reminders: {'minutes': []}, + recurringMutationScope: following + ? RecurringEventMutationScope.thisAndFuture + : RecurringEventMutationScope.singleOccurrence, + ), + ); + final candidate = await _serializedPendingDavMutation( + database, + baseline, + ); + final exception = IcalSemanticDocument.parse(candidate).components + .singleWhere( + (component) => + component.recurrenceId?.rawValue == '20260810T090000Z', + ); + expect(exception.alarms, isEmpty); + Future check() async { + final events = await database.select(database.calendarEvents).get(); + expect( + (jsonDecode( + events + .singleWhere((row) => row.id == occurrence.id) + .remindersJson!, + ) as Map)['alarms'], + isEmpty, + ); + await NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 8, 8, 8), + ).rebuildUpcomingEventNotifications(_accountId); + final reminders = await database + .select(database.notificationSchedule) + .get(); + expect( + reminders.where((row) => row.sourceId == occurrence.id), + isEmpty, + ); + expect(reminders, isNotEmpty); + } + + await check(); + await reopen(); + await check(); + await replayResource(candidate); + await reopen(); + await check(); + }, + ); + } + + for (final rule in [ + 'FREQ=DAILY;UNTIL=20260810', + 'FREQ=DAILY;COUNT=1', + 'FREQ=DAILY;COUNT=2', + ]) { + test( + 'recurring task completion projects the current actionable state for $rule', + () async { + await usePersistentFixture(); + final baseline = _taskResource( + uid: 'final-task', + summary: 'Recurring', + extra: [ + 'DTSTART;VALUE=DATE:20260809', + 'DUE;VALUE=DATE:20260810', + 'RRULE:$rule', + 'STATUS:NEEDS-ACTION', + ], + ); + const href = '${_collectionHref}final-task.ics'; + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"one"', body: baseline), + ]); + final masterId = (await database.select(database.tasks).getSingle()).id; + await tasksRepository.patchTask( + _taskListId, + masterId, + const TaskPatchInput({'status': 'completed'}), + ); + final master = (await database.select(database.tasks).get()) + .singleWhere((row) => row.recurrenceIdKey == null); + final exhausted = !rule.endsWith('COUNT=2'); + expect(master.status, exhausted ? 'completed' : 'needsAction'); + final candidate = await _serializedPendingDavMutation( + database, + baseline, + ); + final semantic = IcalSemanticDocument.parse(candidate); + expect(semantic.components, hasLength(2)); + if (rule.contains('UNTIL')) { + expect( + semantic.components + .singleWhere((row) => row.recurrenceId == null) + .status, + 'NEEDS-ACTION', + ); + await tasksRepository.patchTask( + _taskListId, + masterId, + const TaskPatchInput({'status': 'completed'}), + ); + } + await reopen(); + await replayResource(candidate); + await reopen(); + expect( + (await database.select(database.tasks).get()) + .singleWhere((row) => row.id == masterId) + .status, + exhausted ? 'completed' : 'needsAction', + ); + if (exhausted) { + final editedCompletion = DateTime.utc(2026, 8, 8, 7); + await tasksRepository.patchTask( + _taskListId, + masterId, + TaskPatchInput({ + 'completedAtUtc': editedCompletion.toIso8601String(), + }), + ); + expect( + DateTime.parse( + (await database.select(database.tasks).get()) + .singleWhere((row) => row.id == masterId) + .completedUtc!, + ), + editedCompletion, + ); + await tasksRepository.patchTask( + _taskListId, + masterId, + const TaskPatchInput({'status': 'needsAction'}), + ); + expect( + (await database.select(database.tasks).get()) + .singleWhere((row) => row.id == masterId) + .status, + isNot('completed'), + ); + await reopen(); + final taskTrees = await database.tasksDao.watchAllTaskTrees([ + _accountId, + ]).first; + expect( + taskTrees.where( + (row) => !['completed', 'cancelled'].contains(row.task.status), + ), + hasLength(1), + ); + final openItems = await ScheduleRepository(database).listItems( + range: ScheduleRange( + start: DateTime(2026, 8), + end: DateTime(2026, 9), + ), + filters: const ScheduleFilters( + taskCompletion: ScheduleTaskCompletion.open, + ), + ); + expect(openItems.whereType(), hasLength(1)); + await tasksRepository.patchTask( + _taskListId, + masterId, + const TaskPatchInput({'status': 'completed'}), + ); + expect( + (await database.select(database.tasks).get()) + .singleWhere((row) => row.id == masterId) + .status, + 'completed', + ); + expect(await database.select(database.tasks).get(), hasLength(2)); + } + }, + ); + } for (final remove in [false, true]) { test( @@ -1060,6 +1410,386 @@ void main() { }, ); + for (final drag in [false, true]) { + test( + '${drag ? 'coordinator drag' : 'ordinary weekly series edit'} preserves DAV exceptions through replay, sync and restart', + () async { + await usePersistentFixture(); + const href = '${_collectionHref}weekly-timing.ics'; + final baseline = _ical(const [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax integration test//EN', + 'BEGIN:VEVENT', + 'UID:weekly@example.test', + 'DTSTAMP:20260808T110000Z', + 'DTSTART:20260803T090000Z', + 'DTEND:20260803T100000Z', + 'RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=5', + 'SUMMARY:Weekly', + 'X-SERIES-KEEP:opaque', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:weekly@example.test', + 'RECURRENCE-ID:20260810T090000Z', + 'DTSTAMP:20260808T110000Z', + 'DTSTART:20260812T130000Z', + 'DTEND:20260812T150000Z', + 'SUMMARY:Moved Wednesday', + 'X-EXCEPTION-KEEP:opaque', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:weekly@example.test', + 'RECURRENCE-ID:20260817T090000Z', + 'DTSTAMP:20260808T110000Z', + 'DTSTART:20260817T090000Z', + 'DTEND:20260817T100000Z', + 'STATUS:CANCELLED', + 'END:VEVENT', + 'END:VCALENDAR', + ]); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"one"', body: baseline), + ]); + final row = (await database.select(database.calendarEvents).get()) + .singleWhere( + (event) => event.occurrenceKey!.contains('2026-08-03T09:00:00'), + ); + final draft = _draftForEvent(row).copyWith( + start: DateTime.utc(2026, 8, 4, 9), + end: DateTime.utc(2026, 8, 4, 10), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ); + if (drag) { + final item = + (await ScheduleRepository(database).listItems( + range: ScheduleRange( + start: DateTime.utc(2026, 8), + end: DateTime.utc(2026, 9), + ), + )) + .whereType() + .singleWhere((item) => item.id == row.id); + expect( + await ScheduleReschedulingCoordinator( + repository: calendarRepository, + chooseScope: (_, _) async => + RecurringEventMutationScope.entireSeries, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (_) async {}, + ).commit( + ScheduleRescheduleRequest( + item: item, + interval: ScheduleInterval(draft.start!, draft.end!), + ), + ), + ScheduleRescheduleResult.saved, + ); + } else { + await calendarRepository.updateLocalEvent(draft); + } + final candidate = await _serializedPendingDavMutation( + database, + baseline, + ); + final semantic = IcalSemanticDocument.parse(candidate); + expect( + semantic.components + .singleWhere((component) => component.recurrenceId == null) + .recurrenceRules + .single, + contains('BYDAY=TU'), + ); + final exception = semantic.components.singleWhere( + (c) => c.summary == 'Moved Wednesday', + ); + expect(exception.recurrenceId!.rawValue, '20260811T090000Z'); + expect(exception.start!.rawValue, '20260812T130000Z'); + expect(exception.end!.rawValue, '20260812T150000Z'); + expect( + semantic.components + .singleWhere((c) => c.status == 'CANCELLED') + .recurrenceId! + .rawValue, + '20260818T090000Z', + ); + expect(candidate, contains('X-EXCEPTION-KEEP:opaque')); + Future check() async { + final events = (await database.select(database.calendarEvents).get()) + .where((event) => !event.isDeleted && !event.isCancelled) + .toList(); + final moved = events.singleWhere( + (event) => event.title == 'Moved Wednesday', + ); + expect( + DateTime.parse(moved.startDateTime!), + DateTime.utc(2026, 8, 12, 13), + ); + expect( + DateTime.parse(moved.endDateTime!), + DateTime.utc(2026, 8, 12, 15), + ); + expect( + events + .where((event) => event.title != 'Moved Wednesday') + .map((event) => DateTime.parse(event.startDateTime!).weekday), + everyElement(DateTime.tuesday), + ); + expect( + events.where( + (event) => DateTime.parse(event.startDateTime!).day == 18, + ), + isEmpty, + ); + expect( + events.where( + (event) => DateTime.parse(event.startDateTime!).day == 11, + ), + isEmpty, + ); + } + + await check(); + await reopen(); + await check(); + await replayResource(candidate); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"returned"', body: candidate), + ]); + await reopen(); + await check(); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + } + + test('ordinary all-day series edit keeps a rescheduled exception and cancellation dates', () async { + await usePersistentFixture(); + const href = '${_collectionHref}all-day-series.ics'; + final baseline = _ical(const [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax fixture//EN', + 'BEGIN:VEVENT', + 'UID:all-day-series', + 'DTSTAMP:20260101T000000Z', + 'DTSTART;VALUE=DATE:20260601', + 'DTEND;VALUE=DATE:20260602', + 'RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3', + 'SUMMARY:Regular', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:all-day-series', + 'RECURRENCE-ID;VALUE=DATE:20260608', + 'DTSTAMP:20260101T000000Z', + 'DTSTART;VALUE=DATE:20260610', + 'DTEND;VALUE=DATE:20260612', + 'SUMMARY:Moved Wednesday', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:all-day-series', + 'RECURRENCE-ID;VALUE=DATE:20260615', + 'DTSTAMP:20260101T000000Z', + 'DTSTART;VALUE=DATE:20260615', + 'DTEND;VALUE=DATE:20260616', + 'STATUS:CANCELLED', + 'END:VEVENT', + 'END:VCALENDAR', + ]); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"one"', body: baseline), + ]); + final selected = (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.startDate == '2026-06-01'); + await calendarRepository.updateLocalEvent( + _draftForEvent(selected).copyWith( + start: DateTime.utc(2026, 6, 2), + end: DateTime.utc(2026, 6, 3), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + final candidate = await _serializedPendingDavMutation(database, baseline); + final parsed = IcalSemanticDocument.parse(candidate); + expect( + parsed.components + .singleWhere((c) => c.summary == 'Moved Wednesday') + .recurrenceId! + .rawValue, + '20260609', + ); + expect( + parsed.components + .singleWhere((c) => c.status == 'CANCELLED') + .recurrenceId! + .rawValue, + '20260616', + ); + Future check() async { + final active = (await database.select(database.calendarEvents).get()) + .where((row) => !row.isDeleted && !row.isCancelled) + .toList(); + expect(active, hasLength(2)); + expect( + active.map((row) => row.startDate), + unorderedEquals(['2026-06-02', '2026-06-10']), + ); + final moved = active.singleWhere((row) => row.title == 'Moved Wednesday'); + expect(moved.endDate, '2026-06-12'); + expect(moved.allDay, isTrue); + } + + await check(); + await replayResource(candidate); + await reopen(); + await check(); + }); + + for (final duration in ['P1D', 'PT24H']) { + test( + 'ordinary zoned series edit preserves $duration and exception schedule across DST and restart', + () async { + await usePersistentFixture(); + const href = '${_collectionHref}dst-series.ics'; + final baseline = _ical([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax fixture//EN', + 'BEGIN:VEVENT', + 'UID:dst-series', + 'DTSTAMP:20260101T000000Z', + 'DTSTART;TZID=America/Vancouver:20260302T013000', + 'DURATION:$duration', + 'RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3', + 'SUMMARY:Regular', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:dst-series', + 'RECURRENCE-ID;TZID=America/Vancouver:20260316T013000', + 'DTSTAMP:20260101T000000Z', + 'DTSTART;TZID=America/Vancouver:20260318T130000', + 'DTEND;TZID=America/Vancouver:20260318T150000', + 'SUMMARY:Moved Wednesday', + 'X-KEEP:opaque', + 'END:VEVENT', + 'END:VCALENDAR', + ]); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"one"', body: baseline), + ]); + final selected = (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.startDateTime == '2026-03-02T01:30:00'); + final detail = (await calendarRepository.loadEventDetail(selected.id))!; + await calendarRepository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + start: providerCivilDateTime(DateTime.utc(2026, 3, 1, 1, 30)), + end: providerCivilDateTime(DateTime.utc(2026, 3, 2, 1, 30)), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ), + ); + final candidate = await _serializedPendingDavMutation( + database, + baseline, + ); + expect(candidate, contains('DURATION:$duration')); + final parsed = IcalSemanticDocument.parse(candidate); + final exception = parsed.components.singleWhere( + (c) => c.recurrenceId != null, + ); + expect(exception.recurrenceId!.rawValue, '20260315T013000'); + expect(exception.start!.rawValue, '20260318T130000'); + expect(exception.end!.rawValue, '20260318T150000'); + Future check() async { + final rows = await database.select(database.calendarEvents).get(); + final dst = rows.singleWhere( + (row) => row.startDateTime == '2026-03-08T01:30:00', + ); + expect(dst.startTimeZone, 'America/Vancouver'); + expect( + dst.endDateTime, + duration == 'P1D' ? '2026-03-09T01:30:00' : '2026-03-09T02:30:00', + ); + expect( + providerDateTimeAsUtcInstant(dst.endDateTime, dst.endTimeZone)! + .difference( + providerDateTimeAsUtcInstant( + dst.startDateTime, + dst.startTimeZone, + )!, + ) + .inHours, + duration == 'P1D' ? 23 : 24, + ); + expect( + rows + .singleWhere((row) => row.title == 'Moved Wednesday') + .startDateTime, + '2026-03-18T13:00:00', + ); + } + + await check(); + await reopen(); + await check(); + await replayResource(candidate); + await reopen(); + await check(); + }, + ); + } + + test('a ranged exception with varying monthly offsets requires a decision before mutation', () async { + const href = '${_collectionHref}ranged-series.ics'; + final baseline = _ical(const [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax fixture//EN', + 'BEGIN:VEVENT', + 'UID:ranged-series', + 'DTSTAMP:20260101T000000Z', + 'DTSTART:20260126T090000Z', + 'DTEND:20260126T100000Z', + 'RRULE:FREQ=MONTHLY;BYDAY=-1MO;COUNT=8', + 'SUMMARY:Regular', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:ranged-series', + 'RECURRENCE-ID;RANGE=THISANDFUTURE:20260223T090000Z', + 'DTSTAMP:20260101T000000Z', + 'DTSTART:20260225T090000Z', + 'DTEND:20260225T100000Z', + 'SUMMARY:Following Wednesdays', + 'END:VEVENT', + 'END:VCALENDAR', + ]); + await _commitObjects(objectRepository, [ + _prepared(href: href, etag: '"one"', body: baseline), + ]); + final selected = (await database.select(database.calendarEvents).get()) + .singleWhere( + (row) => row.occurrenceKey!.contains('2026-01-26T09:00:00'), + ); + final draft = _draftForEvent(selected).copyWith( + start: DateTime.utc(2026, 1, 27, 9), + end: DateTime.utc(2026, 1, 27, 10), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ); + await expectLater( + calendarRepository.prepareSeriesTimingChange(draft), + throwsA(isA()), + ); + await expectLater( + calendarRepository.updateLocalEvent(draft), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.id == selected.id) + .startDateTime, + '2026-01-26T09:00:00.000Z', + ); + }); + test( 'entire-series edit patches the master anchor and preserves exceptions', () async { @@ -1098,7 +1828,7 @@ void main() { final exception = semantic.components.singleWhere( (component) => component.recurrenceId != null, ); - expect(exception.recurrenceId?.rawValue, '20260809T090000Z'); + expect(exception.recurrenceId?.rawValue, '20260809T100000Z'); expect(exception.start?.rawValue, '20260809T110000Z'); expect(exception.summary, 'Existing exception'); expect(candidate, contains('X-SERIES-KEEP:opaque')); diff --git a/test/dav/nextcloud/nextcloud_dialogs_test.dart b/test/dav/nextcloud/nextcloud_dialogs_test.dart index 31bdf0d7..648e91fc 100644 --- a/test/dav/nextcloud/nextcloud_dialogs_test.dart +++ b/test/dav/nextcloud/nextcloud_dialogs_test.dart @@ -42,6 +42,60 @@ void main() { ); }); + for (final (property, invalid, valid, message) in [ + ('displayname', ' ', 'Repaired QA collection', 'This field is required.'), + ( + 'calendar-color', + 'red?', + '#12345678', + 'Enter a six- or eight-digit hexadecimal color, starting with #.', + ), + ('calendar-order', 'first', '4', 'Enter a whole number.'), + ( + 'calendar-timezone', + 'not a timezone', + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTIMEZONE\r\nTZID:Etc/UTC\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n', + 'Enter a valid calendar timezone document containing VTIMEZONE and TZID.', + ), + ]) { + testWidgets( + 'Linux validates $property locally, retains its draft and allows correction', + (tester) async { + final fixture = NextcloudAdminFixture(); + await tester.runAsync(fixture.seed); + addTearDown(fixture.close); + await _pump(tester, fixture, false); + if (property == 'calendar-timezone') { + await tester.ensureVisible(find.text('Advanced')); + await tester.tap(find.text('Advanced')); + await tester.pumpAndSettle(); + } + final field = find.byKey(ValueKey('nextcloud-$property')); + await tester.ensureVisible(field); + await tester.enterText(field, invalid); + await tester.pump(); + final requests = fixture.requests.length; + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text(message), findsOneWidget); + expect(fixture.requests.length, requests); + expect(tester.widget(field).controller!.text, invalid); + expect(find.text('Nextcloud could not be reached.'), findsNothing); + await tester.enterText(field, valid); + await tester.pump(); + await tester.tap(find.text('Save')); + await _settle(tester); + expect( + fixture.requests.where((r) => r.method == 'PROPPATCH'), + hasLength(1), + ); + expect(find.text(message), findsNothing); + expect(tester.widget(field).controller!.text, valid); + await tester.pumpWidget(const SizedBox()); + }, + ); + } + for (final windows in [false, true]) { final platform = windows ? 'Windows' : 'Linux'; testWidgets('$platform initiates a federated calendar share', ( diff --git a/test/dav/storage/dav_object_repository_test.dart b/test/dav/storage/dav_object_repository_test.dart index a12a29fb..ba90bf9e 100644 --- a/test/dav/storage/dav_object_repository_test.dart +++ b/test/dav/storage/dav_object_repository_test.dart @@ -16,7 +16,7 @@ import 'package:busymax/src/features/notifications/notification_schedule_service import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; -import 'package:drift/drift.dart'; +import 'package:drift/drift.dart' hide isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -38,6 +38,107 @@ void main() { tearDown(() => database.close()); + test('period RDATE survives DAV ingestion, late overlap and end-relative reminders', () async { + const href = '/remote.php/dav/calendars/alex/work/period.ics'; + final raw = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax Test//EN', + 'BEGIN:VEVENT', + 'UID:period', + 'SUMMARY:Long occurrence', + 'DTSTART:20260809T090000Z', + 'DURATION:PT1H', + 'RDATE;VALUE=PERIOD:20260810T090000Z/PT3H', + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:End reminder', + 'TRIGGER;RELATED=END:-PT30M', + 'END:VALARM', + 'END:VEVENT', + 'END:VCALENDAR', + '', + ].join('\r\n'); + final prepared = DavPreparedObject.parse( + hrefKey: href, + requestUri: Uri.parse('https://cloud.example.test$href'), + etag: '"period"', + contentType: 'text/calendar', + rawIcsBody: raw, + ); + await repository.commit( + _commit(objects: [prepared], membership: {href}, cursor: 'period'), + ); + expect( + (await database.select(database.davObjects).getSingle()).rawIcsBody, + raw, + ); + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange( + start: DateTime.utc(2026, 8, 10, 11).toLocal(), + end: DateTime.utc(2026, 8, 10, 13).toLocal(), + ), + ); + expect(items, hasLength(1)); + expect(items.single.end?.toUtc(), DateTime.utc(2026, 8, 10, 12)); + await NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 8, 8), + ).rebuildUpcomingEventNotifications('account'); + final event = (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.startDateTime!.startsWith('2026-08-10')); + final notification = + (await database.select(database.notificationSchedule).get()) + .singleWhere((row) => row.sourceId == event.id); + expect( + DateTime.fromMillisecondsSinceEpoch( + notification.scheduledAtUtc, + isUtc: true, + ), + DateTime.utc(2026, 8, 10, 11, 30), + ); + }); + + for (final deadlines in [false, true]) { + test( + 'desktop summary distinguishes a real DAV deadline from a start fallback ($deadlines)', + () async { + await _importNotificationTask( + repository, + due: deadlines + ? 'DTSTART;VALUE=DATE:20260809\r\nDUE;VALUE=DATE:20260809' + : 'DTSTART;VALUE=DATE:20260809', + ); + final stored = await database.select(database.tasks).getSingle(); + expect(stored.dueUtc, isNotNull); + expect( + jsonDecode(stored.providerMetadataJson!)['nativeStart'], + isNotNull, + ); + final backend = RecordingNotificationBackend(); + final scheduler = DueTodayNotificationScheduler( + database: database, + settings: () => AppSettings.defaults().copyWith(notifyDueToday: true), + activeAccountId: () => 'account', + notifications: () => DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults().copyWith(notifyDueToday: true), + ), + markNotified: (_) async {}, + now: () => DateTime(2026, 8, 9, 9), + ); + addTearDown(scheduler.stop); + await scheduler.checkNow(); + expect(backend.requests, hasLength(deadlines ? 1 : 0)); + expect( + await ScheduleRepository(database) + .listItems(range: ScheduleRange.day(DateTime(2026, 8, 9))), + hasLength(1), + ); + }, + ); + } + for (final due in [ (property: 'DUE;VALUE=DATE:20260809', local: DateTime(2026, 8, 9)), (property: 'DUE:20260809T140000', local: DateTime(2026, 8, 9, 14)), @@ -169,13 +270,21 @@ void main() { isTrue, ); expect( - events.every( - (event) => - event.remindersJson?.contains('"minutes":[10,5,30]') ?? false, - ), + events + .where((event) => event.title != 'Moved') + .every( + (event) => + event.remindersJson?.contains('"minutes":[10,5,30]') ?? false, + ), isTrue, ); expect(events.first.remindersJson, contains('AUDIO')); + expect( + jsonDecode( + events.singleWhere((event) => event.title == 'Moved').remindersJson!, + )['alarms'], + isEmpty, + ); expect( events.every( (event) => event.projectionVersion == davProjectionVersion, diff --git a/test/dav/sync/dav_sync_engine_test.dart b/test/dav/sync/dav_sync_engine_test.dart index 5246542c..92163609 100644 --- a/test/dav/sync/dav_sync_engine_test.dart +++ b/test/dav/sync/dav_sync_engine_test.dart @@ -530,7 +530,7 @@ void main() { expect(objectAfter.etag, '"weekly"'); final cursor = await database.select(database.syncCursors).getSingle(); expect(cursor.cursorValue, 'next-token'); - expect(cursor.stateJson, contains('"projectionVersion":5')); + expect(cursor.stateJson, contains('"projectionVersion":6')); }, ); } diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index d683e1a2..51992997 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -23,6 +23,114 @@ void main() { await database.close(); }); + for (final failurePoint in ['transition', 'indexes', 'version']) { + test( + 'supported migration rolls back and reopens after $failurePoint failure', + () async { + await database.close(); + final fixture = await _openSchema5Fixture(failurePoint: failurePoint); + database = fixture.database; + final file = File('${fixture.directory.path}/busymax.sqlite'); + await expectLater( + database.customSelect('PRAGMA user_version').get(), + throwsStateError, + ); + await database.close(); + final raw = sqlite3.sqlite3.open(file.path); + try { + expect(raw.select('PRAGMA user_version').single.values.single, 5); + expect( + raw.select('SELECT count(*) AS n FROM accounts').single['n'], + 2, + ); + expect( + raw.select('SELECT count(*) AS n FROM pending_ops').single['n'], + 3, + ); + expect( + raw.select( + "SELECT name FROM sqlite_master WHERE name = 'o_auth_transition_accounts'", + ), + isEmpty, + ); + expect(raw.select('PRAGMA foreign_key_check'), isEmpty); + } finally { + raw.close(); + } + database = AppDatabase(NativeDatabase(file)); + expect( + (await database.customSelect('PRAGMA user_version').getSingle()) + .read('user_version'), + latestSchemaVersion, + ); + expect( + await database.select(database.oAuthTransitionAccounts).get(), + hasLength(2), + ); + expect(await database.select(database.pendingOps).get(), hasLength(3)); + expect( + await database.customSelect('PRAGMA foreign_key_check').get(), + isEmpty, + ); + expect( + (await database.customSelect('PRAGMA foreign_keys').getSingle()) + .read('foreign_keys'), + 1, + ); + expect( + await database + .customSelect( + "SELECT name FROM sqlite_master WHERE type = 'trigger'", + ) + .get(), + isNotEmpty, + ); + await database.close(); + await fixture.directory.delete(recursive: true); + database = AppDatabase.memoryForTests(); + }, + ); + } + + test( + 'interruption after migration commit reopens without repeating the upgrade', + () async { + await database.close(); + final fixture = await _openSchema5Fixture(failurePoint: 'after_commit'); + database = fixture.database; + await expectLater( + database.customSelect('SELECT 1').get(), + throwsStateError, + ); + await database.close(); + final file = File('${fixture.directory.path}/busymax.sqlite'); + final raw = sqlite3.sqlite3.open(file.path); + try { + expect( + raw.select('PRAGMA user_version').single.values.single, + latestSchemaVersion, + ); + expect( + raw.select('SELECT account_id FROM o_auth_transition_accounts'), + hasLength(2), + ); + expect(raw.select('PRAGMA foreign_key_check'), isEmpty); + } finally { + raw.close(); + } + database = AppDatabase(NativeDatabase(file)); + expect(await database.select(database.accounts).get(), hasLength(2)); + expect(await database.select(database.pendingOps).get(), hasLength(3)); + expect( + await database.select(database.oAuthTransitionAccounts).get(), + hasLength(2), + ); + await database.close(); + await fixture.directory.delete(recursive: true); + database = AppDatabase.memoryForTests(); + }, + ); + test('opens the latest schema and creates required indexes', () async { final version = await database .customSelect('PRAGMA user_version') @@ -1234,6 +1342,7 @@ void _removeProviderCredentialPairConstraint(sqlite3.Database database) { Future<({AppDatabase database, Directory directory})> _openSchema5Fixture({ void Function(sqlite3.Database raw)? prepare, + String? failurePoint, }) async { final directory = await createPersistenceTestDirectory( 'busymax-schema5-fixture-', @@ -1248,5 +1357,34 @@ Future<({AppDatabase database, Directory directory})> _openSchema5Fixture({ } finally { raw.close(); } - return (database: AppDatabase(NativeDatabase(file)), directory: directory); + final executor = NativeDatabase(file); + return ( + database: failurePoint == null + ? AppDatabase(executor) + : _FailingMigrationDatabase(executor, failurePoint), + directory: directory, + ); +} + +class _FailingMigrationDatabase extends AppDatabase { + _FailingMigrationDatabase(super.executor, this.point); + final String point; + bool populated = false; + @override + Future customStatement(String statement, [List? args]) async { + if (point == 'after_commit' && statement == 'PRAGMA foreign_keys = ON') { + throw StateError('injected before Drift advances the version'); + } + if (point == 'indexes' && populated && statement.contains('CREATE INDEX')) { + throw StateError('injected late migration failure'); + } + if (point == 'version' && statement.contains('PRAGMA user_version =')) { + throw StateError('injected version failure'); + } + await super.customStatement(statement, args); + if (statement.startsWith('INSERT INTO o_auth_transition_accounts')) { + populated = true; + if (point == 'transition') throw StateError('injected after population'); + } + } } diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index abbfd43c..59881b3b 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -112,7 +112,7 @@ void main() { for (final mode in ['wrong-account', 'cancel', 'unverified', 'offline']) { testWidgets( - 'targeted Google recovery $mode retains the old account and offers safe recovery', + 'targeted Google registration recovery $mode retains the old account and offers safe recovery', (tester) async { await _insertAccount( database, @@ -161,20 +161,23 @@ void main() { container .read(appRouterProvider) .go('/settings?page=accounts&reconnect=legacy-local'); - for ( - var i = 0; - i < 20 && - find - .byKey(const ValueKey('registration-methods-dialog')) - .evaluate() - .isEmpty; - i++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pumpAndSettle(); - } + await _waitForAccountAction( + tester, + find.byKey( + const ValueKey('replace-registration-recovery-legacy-local'), + ), + ); + // Let the first, expected missing-registration message expire before + // asserting the result of the explicit replacement attempt. + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(fixture.flow.clients, isEmpty); + await tester.tap( + find.byKey( + const ValueKey('replace-registration-recovery-legacy-local'), + ), + ); + await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('registration-reconnect-account')), findsOneWidget, @@ -207,23 +210,12 @@ void main() { findsOneWidget, ); } else if (mode == 'cancel') { - container - .read(appRouterProvider) - .go('/settings?page=accounts&reconnect=legacy-local'); - for ( - var i = 0; - i < 20 && - find - .byKey(const ValueKey('registration-methods-dialog')) - .evaluate() - .isEmpty; - i++ - ) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pumpAndSettle(); - } + final retry = find.byKey( + const ValueKey('replace-registration-recovery-legacy-local'), + ); + await tester.ensureVisible(retry); + await tester.tap(retry); + await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('registration-reconnect-account')), findsOneWidget, @@ -379,8 +371,9 @@ void main() { () => Future.delayed(const Duration(milliseconds: 20)), ); await tester.pumpAndSettle(); - await tester.ensureVisible(find.text('Connect')); - await tester.tap(find.text('Connect')); + await _expandAccountAdvanced(tester, id); + await tester.ensureVisible(find.text('Reconnect this account')); + await tester.tap(find.text('Reconnect this account')); await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('registration-methods-dialog')), @@ -400,6 +393,7 @@ void main() { record.registration.clientId, record.registration.clientId, ]); + await _expandAccountAdvanced(tester, id); await tester.ensureVisible(find.text('Replace registration')); await tester.tap(find.text('Replace registration')); await tester.pumpAndSettle(); @@ -560,14 +554,28 @@ void main() { ); await tester.pumpAndSettle(); await _openSettings(tester); - expect(find.text('Migrate now'), findsNWidgets(2)); + await _expandAccountAdvanced(tester, 'google:existing'); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsScreen)), + ); + final summaries = (await tester.runAsync( + () => container.read(registrationSummariesProvider.future), + ))!; + expect(summaries['google:existing']!.showRetirementNotice, isTrue); + expect(summaries['microsoft:existing']!.showRetirementNotice, isTrue); + expect(find.text('Migrate now'), findsOneWidget); + expect( + find.byKey(const ValueKey('account-detail-microsoft:existing')), + findsNothing, + ); await tester.ensureVisible(find.text('Migrate now').first); await tester.tap(find.text('Migrate now').first); await tester.pumpAndSettle(); await tester.tap(find.text('Cancel').last); await tester.pumpAndSettle(); - expect(find.text('Migrate now'), findsNWidgets(2)); + expect(find.text('Migrate now'), findsOneWidget); expect(flow.clients, isEmpty); + await tester.ensureVisible(find.text('Migrate now').first); await tester.tap(find.text('Migrate now').first); await tester.pumpAndSettle(); await _validateAndAuthorizeGoogleSetup(tester); @@ -586,7 +594,7 @@ void main() { } } await tester.pumpAndSettle(); - expect(find.text('Migrate now'), findsOneWidget); + expect(find.text('Migrate now'), findsNothing); expect( (await tester.runAsync(() => secrets.readCredential('google:existing'))) as GoogleDesktopCredential, @@ -602,6 +610,14 @@ void main() { ), isNull, ); + expect( + (await tester.runAsync( + () => secrets.readCredential('microsoft:existing'), + ))!.toJson(), + records['microsoft:existing']!.toJson(), + ); + await _expandAccountAdvanced(tester, 'microsoft:existing'); + expect(find.text('Migrate now'), findsOneWidget); await _disposeApp(tester); await _pumpApp( tester, @@ -613,8 +629,11 @@ void main() { ); await tester.pumpAndSettle(); await _openSettings(tester); - expect(find.text('Migrate now'), findsOneWidget); + await _expandAccountAdvanced(tester, 'google:existing'); + expect(find.text('Migrate now'), findsNothing); expect(find.textContaining('User-provided registration'), findsOneWidget); + await _expandAccountAdvanced(tester, 'microsoft:existing'); + expect(find.text('Migrate now'), findsOneWidget); await _disposeApp(tester); }, ); @@ -776,10 +795,17 @@ void main() { ); final original = container.read(authSessionControllerProvider); calls.clear(); - final action = find.byKey(const ValueKey('reconnect-account-account-1')); + await _openAccountDetails(tester, 'account-1'); + final action = find.byKey(const ValueKey('account-recovery-account-1')); await tester.ensureVisible(action); await tester.tap(action); await tester.pumpAndSettle(); + final replacement = find.byKey( + const ValueKey('replace-registration-recovery-account-1'), + ); + await _waitForAccountAction(tester, replacement); + await tester.tap(replacement); + await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('registration-reconnect-account')), findsOneWidget, @@ -792,7 +818,8 @@ void main() { .authState, accountAuthStateReauthRequired, ); - await tester.tap(action); + await tester.ensureVisible(replacement); + await tester.tap(replacement); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('registration-busymax'))); for (var i = 0; i < 40; i++) { @@ -1072,6 +1099,7 @@ void main() { GoRouter.of(workspace.context).push('/settings?page=accounts'), ); await tester.pumpAndSettle(); + await _openAccountDetails(tester, 'google:last'); await tester.ensureVisible(find.text('Remove account…')); await tester.tap(find.text('Remove account…')); await tester.pumpAndSettle(); @@ -1398,6 +1426,8 @@ void main() { ); final router = container.read(appRouterProvider); + await _openAccountDetails(tester, 'google:remove'); + await tester.ensureVisible(find.text('Remove account…').first); await tester.tap(find.text('Remove account…').first); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('confirm-account-removal'))); @@ -1462,6 +1492,8 @@ void main() { expect(settingsUri.path, '/settings'); expect(settingsUri.queryParameters['page'], 'accounts'); + await _openAccountDetails(tester, 'google:remove'); + await tester.ensureVisible(find.text('Remove account…').first); await tester.tap(find.text('Remove account…').first); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('confirm-account-removal'))); @@ -1661,12 +1693,12 @@ void main() { ); final action = find.byKey( - const ValueKey('reconnect-account-google:reconnect'), + const ValueKey('account-recovery-google:reconnect'), ); + await _openAccountDetails(tester, 'google:reconnect'); await tester.ensureVisible(action); await tester.tap(action); await tester.pumpAndSettle(); - await _validateAndAuthorizeGoogleSetup(tester); await tester.pump(); final sessionWhileReconnecting = container.read( @@ -1705,6 +1737,53 @@ void main() { ); } +Future _waitForAccountAction(WidgetTester tester, Finder action) async { + for (var i = 0; i < 40 && action.evaluate().isEmpty; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pumpAndSettle(); + } + expect(action, findsOneWidget); + await tester.ensureVisible(action); + await tester.pumpAndSettle(); +} + +Future _expandAccountAdvanced(WidgetTester tester, String id) async { + await _openAccountDetails(tester, id); + final advanced = find.byKey(ValueKey('account-advanced-$id')); + await tester.ensureVisible(advanced); + await tester.pumpAndSettle(); + final fade = find.descendant( + of: advanced, + matching: find.byType(AnimatedCrossFade), + ); + if (tester.widget(fade.first).crossFadeState == + CrossFadeState.showSecond) { + await tester.tap( + find.descendant(of: advanced, matching: find.text('Advanced')), + ); + await tester.pumpAndSettle(); + } +} + +Future _openAccountDetails(WidgetTester tester, String id) async { + if (find.byKey(ValueKey('account-detail-$id')).evaluate().isNotEmpty) return; + final openDetail = find.byWidgetPredicate((widget) { + final key = widget.key; + return key is ValueKey && key.value.startsWith('account-detail-'); + }); + if (openDetail.evaluate().isNotEmpty) { + await tester.tap(find.byKey(const ValueKey('settings-header-back-button'))); + await tester.pumpAndSettle(); + } + final entry = find.byKey(ValueKey('account-entry-$id')); + await _waitForAccountAction(tester, entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + expect(find.byKey(ValueKey('account-detail-$id')), findsOneWidget); +} + Future _connectGoogleThroughSettings(WidgetTester tester) async { await _authorizeGoogleSetup(tester); await tester.pumpAndSettle(); diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index dcfe5530..5d170940 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:convert'; @@ -21,6 +22,7 @@ import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; import 'package:busymax/src/google_calendar/google_calendar_models.dart'; +import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_models.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; @@ -41,6 +43,142 @@ const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); void main() { + testWidgets( + 'ordinary monthly series edit shows the exact proposal and requires confirmation', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1400, 1000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'fixture', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: '2026-01-01T00:00:00Z', + updatedAtUtc: '2026-01-01T00:00:00Z', + ), + ); + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Fixture', + timeZone: 'UTC', + accessRole: 'owner', + ), + ); + final master = { + 'id': 'series', + 'summary': 'Monthly', + 'iCalUID': 'fixture', + 'organizer': {'self': true}, + 'start': {'dateTime': '2026-01-31T09:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-01-31T10:00:00Z', 'timeZone': 'UTC'}, + 'recurrence': ['RRULE:FREQ=MONTHLY;BYMONTHDAY=31;COUNT=4'], + }; + await repository.upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('calendar', master), + ); + await repository.upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('calendar', { + ...master, + 'id': 'occurrence', + 'recurrence': null, + 'recurringEventId': 'series', + 'originalStartTime': {'dateTime': '2026-01-31T09:00:00Z'}, + }), + ); + final occurrence = (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.providerEventId == 'occurrence'); + final detail = (await repository.loadEventDetail(occurrence.id))!; + final desired = EventEditorDraft.fromEventDetail(detail).copyWith( + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ); + final source = await database + .select(database.calendarSources) + .getSingle(); + EventEditorDraft? saved; + final persisted = Completer(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + calendarRepositoryProvider.overrideWithValue(repository), + googleEventLabelsForCalendarProvider.overrideWith( + (ref, key) async => const [], + ), + ], + child: localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + final result = await showBusyMaxEventEditorDialog( + context, + initialDraft: desired, + sources: [CalendarSourceEntity.fromRow(source)], + accounts: const [], + ); + saved = result?.draft; + if (saved != null) { + await repository.updateLocalEvent(saved!); + persisted.complete(); + } + }, + child: const Text('Open series editor'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open series editor')); + await tester.pumpAndSettle(); + tester + .widget( + find.byWidgetPredicate( + (widget) => + widget is DesktopDateValueRow && widget.label == 'Start date', + ), + ) + .onChanged('2026-02-01'); + await tester.pumpAndSettle(); + await tester.tap(_headerButtonFinder('Save')); + await tester.pumpAndSettle(); + final confirmation = find.byType(BusyMaxConfirmDialog); + expect(confirmation, findsOneWidget); + final message = tester.widget(confirmation).message; + expect(message, contains('Current: Monthly on day 31 · 4 times')); + expect(message, contains('Proposed: Monthly on day 1 · 4 times')); + await tester.tap( + find.descendant(of: confirmation, matching: find.text('Cancel')), + ); + await tester.pumpAndSettle(); + expect(find.byType(EventEditor), findsOneWidget); + expect(saved, isNull); + expect(await database.select(database.pendingOps).get(), isEmpty); + await tester.tap(_headerButtonFinder('Save')); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant(of: confirmation, matching: find.text('Save')), + ); + await tester.pumpAndSettle(); + await tester.runAsync(() => persisted.future); + expect(saved?.seriesTimingApproval, isNotNull); + expect(await database.select(database.pendingOps).get(), hasLength(1)); + }, + ); + testWidgets('Google all-day Linux availability uses calendar timezone', ( tester, ) async { diff --git a/test/features/recurrence/domain/event_recurrence_codec_test.dart b/test/features/recurrence/domain/event_recurrence_codec_test.dart index d87b2ee9..f6873fdf 100644 --- a/test/features/recurrence/domain/event_recurrence_codec_test.dart +++ b/test/features/recurrence/domain/event_recurrence_codec_test.dart @@ -5,6 +5,45 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('RFC 5545 recurrence', () { + test( + 'yearly month-day expansion survives the editable rule round trip', + () { + final rule = EventRecurrenceCodec.decode(BusyProvider.google, const [ + 'RRULE:FREQ=YEARLY;INTERVAL=2;BYMONTHDAY=1;COUNT=24', + ], baseDate: DateTime(2026, 1, 1)); + expect(rule.isSupported, isTrue); + expect(rule.byMonth, List.generate(12, (index) => index + 1)); + expect(rule.byMonthDay, [1]); + expect(rule.interval, 2); + expect(rule.count, 24); + final encoded = EventRecurrenceCodec.encode( + BusyProvider.google, + rule, + baseDate: DateTime(2026, 1, 1), + allDay: true, + ); + final decoded = EventRecurrenceCodec.decode( + BusyProvider.google, + encoded, + baseDate: DateTime(2026, 1, 1), + ); + expect(decoded.byMonth, rule.byMonth); + final restricted = EventRecurrenceCodec.decode( + BusyProvider.google, + const ['RRULE:FREQ=YEARLY;BYMONTH=2,4;BYMONTHDAY=1'], + baseDate: DateTime(2026, 2, 1), + ); + expect(restricted.byMonth, [2, 4]); + final anniversary = EventRecurrenceCodec.decode( + BusyProvider.google, + const ['RRULE:FREQ=YEARLY'], + baseDate: DateTime(2026, 3, 13), + ); + expect(anniversary.byMonth, [3]); + expect(anniversary.byMonthDay, [13]); + }, + ); + test('round-trips an every-two-weeks weekday set with a count', () { final rule = EventRecurrenceCodec.decode(BusyProvider.google, const [ 'RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;WKST=MO;COUNT=12', diff --git a/test/features/recurrence/domain/series_timing_change_test.dart b/test/features/recurrence/domain/series_timing_change_test.dart new file mode 100644 index 00000000..570068d3 --- /dev/null +++ b/test/features/recurrence/domain/series_timing_change_test.dart @@ -0,0 +1,320 @@ +import 'package:busymax/src/features/recurrence/domain/series_timing_change.dart'; +import 'package:busymax/src/features/recurrence/domain/event_recurrence_codec.dart'; +import 'package:busymax/src/dav/ical/ical_recurrence.dart'; +import 'package:busymax/src/dav/ical/ical_semantics.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + SeriesTimingChange plan( + String rule, + DateTime start, + DateTime before, + DateTime after, { + BusyProvider provider = BusyProvider.google, + bool allDay = false, + String? zone = 'UTC', + }) { + final original = provider == BusyProvider.microsoft + ? EventRecurrenceCodec.encode( + provider, + EventRecurrenceCodec.decode(BusyProvider.google, [ + 'RRULE:$rule', + ], baseDate: start), + baseDate: start, + allDay: allDay, + timeZone: zone, + )! + : ['RRULE:$rule']; + return SeriesTimingChange.plan( + provider: provider, + original: original, + masterStart: start, + occurrenceStart: before, + desiredStart: after, + allDay: allDay, + originalTimeZone: zone, + timeZone: zone, + ); + } + + test( + 'weekly edit translates ordinary dates and survives its persisted request', + () { + for (final provider in [ + BusyProvider.google, + BusyProvider.microsoft, + BusyProvider.nextcloud, + BusyProvider.appleICloud, + ]) { + final value = plan( + 'FREQ=WEEKLY;BYDAY=MO;COUNT=5', + DateTime.utc(2026, 6, 1, 9), + DateTime.utc(2026, 6, 8, 9), + DateTime.utc(2026, 6, 9, 9), + provider: provider, + ); + expect(value.rule.byDay, ['TU']); + expect(value.requiresConfirmation, isFalse); + final restored = SeriesTimingChange.fromJson(value.toJson()); + expect( + restored.mapOccurrence(DateTime.utc(2026, 6, 15, 9)), + DateTime.utc(2026, 6, 16, 9), + ); + expect(_expand(restored, DateTime.utc(2026, 7)), [ + DateTime.utc(2026, 6, 2, 9), + DateTime.utc(2026, 6, 9, 9), + DateTime.utc(2026, 6, 16, 9), + DateTime.utc(2026, 6, 23, 9), + DateTime.utc(2026, 6, 30, 9), + ]); + } + }, + ); + test( + 'interval and multi-weekday phase survive wrapping the week boundary', + () { + final value = plan( + 'FREQ=WEEKLY;INTERVAL=2;BYDAY=FR,SU;WKST=MO;COUNT=6', + DateTime.utc(2026, 6, 5, 9), + DateTime.utc(2026, 6, 5, 9), + DateTime.utc(2026, 6, 7, 9), + ); + expect(value.rule.interval, 2); + expect(value.rule.byDay, ['SU', 'TU']); + expect(_expand(value, DateTime.utc(2026, 8)), [ + DateTime.utc(2026, 6, 7, 9), + DateTime.utc(2026, 6, 9, 9), + DateTime.utc(2026, 6, 21, 9), + DateTime.utc(2026, 6, 23, 9), + DateTime.utc(2026, 7, 5, 9), + DateTime.utc(2026, 7, 7, 9), + ]); + }, + ); + test( + 'monthly ordinal reanchors within each month, rather than adding a day', + () { + final value = plan( + 'FREQ=MONTHLY;BYDAY=-1MO;COUNT=3', + DateTime.utc(2026, 1, 26, 9), + DateTime.utc(2026, 2, 23, 9), + DateTime.utc(2026, 2, 24, 9), + ); + expect(value.requiresConfirmation, isFalse); + expect(value.rule.bySetPosition, -1); + expect(_expand(value, DateTime.utc(2026, 4)), [ + DateTime.utc(2026, 1, 27, 9), + DateTime.utc(2026, 2, 24, 9), + DateTime.utc(2026, 3, 31, 9), + ]); + }, + ); + test('cross-month ordinal proposes its actual new position for approval', () { + final value = plan( + 'FREQ=MONTHLY;BYDAY=-1MO;COUNT=3', + DateTime.utc(2026, 8, 31, 9), + DateTime.utc(2026, 8, 31, 9), + DateTime.utc(2026, 9, 1, 9), + ); + expect(value.requiresConfirmation, isTrue); + expect(value.rule.bySetPosition, 1); + expect(value.rule.byDay, ['TU']); + expect(_expand(value, DateTime.utc(2026, 12)), [ + DateTime.utc(2026, 9, 1, 9), + DateTime.utc(2026, 10, 6, 9), + DateTime.utc(2026, 11, 3, 9), + ]); + }); + test('monthly 31st to 1st requires approval and preserves COUNT mode', () { + final value = plan( + 'FREQ=MONTHLY;BYMONTHDAY=31;COUNT=4', + DateTime.utc(2026, 1, 31, 9), + DateTime.utc(2026, 1, 31, 9), + DateTime.utc(2026, 2, 1, 9), + ); + expect(value.requiresConfirmation, isTrue); + expect(value.rule.count, 4); + expect(value.rule.untilRaw, isNull); + expect(_expand(value, DateTime.utc(2026, 9)), [ + DateTime.utc(2026, 2, 1, 9), + DateTime.utc(2026, 3, 1, 9), + DateTime.utc(2026, 4, 1, 9), + DateTime.utc(2026, 5, 1, 9), + ]); + expect(value.containsNewOccurrence(DateTime.utc(2026, 6, 1, 9)), isFalse); + }); + test('ordinary monthly date change retains its pattern type', () { + final value = plan( + 'FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=5;COUNT=3', + DateTime.utc(2026, 1, 5, 9), + DateTime.utc(2026, 3, 5, 9), + DateTime.utc(2026, 3, 6, 9), + ); + expect(value.requiresConfirmation, isFalse); + expect(value.rule.byMonthDay, [6]); + expect(_expand(value, DateTime.utc(2026, 7)), [ + DateTime.utc(2026, 1, 6, 9), + DateTime.utc(2026, 3, 6, 9), + DateTime.utc(2026, 5, 6, 9), + ]); + }); + test( + 'time-only edit retains all recurrence dates, selectors and interval', + () { + final value = plan( + 'FREQ=MONTHLY;INTERVAL=2;BYDAY=-1MO;COUNT=3', + DateTime.utc(2026, 1, 26, 9), + DateTime.utc(2026, 3, 30, 9), + DateTime.utc(2026, 3, 30, 11), + ); + expect(value.requiresConfirmation, isFalse); + expect(value.rule.byDay, ['MO']); + expect(value.rule.bySetPosition, -1); + expect(value.rule.interval, 2); + expect(_expand(value, DateTime.utc(2026, 7)), [ + DateTime.utc(2026, 1, 26, 11), + DateTime.utc(2026, 3, 30, 11), + DateTime.utc(2026, 5, 25, 11), + ]); + }, + ); + test( + 'UNTIL date remains fixed and cannot silently drop the edited occurrence', + () { + final value = plan( + 'FREQ=WEEKLY;BYDAY=MO;UNTIL=20261231T090000Z', + DateTime.utc(2026, 6, 1, 9), + DateTime.utc(2026, 6, 8, 9), + DateTime.utc(2026, 6, 9, 10), + ); + expect(value.rule.untilDateFor(timeZone: 'UTC'), '2026-12-31'); + expect(value.rule.count, isNull); + expect( + () => plan( + 'FREQ=WEEKLY;BYDAY=MO;UNTIL=20260608T090000Z', + DateTime.utc(2026, 6, 1, 9), + DateTime.utc(2026, 6, 8, 9), + DateTime.utc(2026, 6, 9, 9), + ), + throwsA(isA()), + ); + }, + ); + test('all-day and Vancouver DST keep civil recurrence positions', () { + for (final allDay in [false, true]) { + final value = plan( + 'FREQ=WEEKLY;BYDAY=MO;COUNT=3', + DateTime.utc(2026, 3, 2, allDay ? 0 : 9), + DateTime.utc(2026, 3, 9, allDay ? 0 : 9), + DateTime.utc(2026, 3, 10, allDay ? 0 : 9), + allDay: allDay, + zone: allDay ? null : 'America/Vancouver', + ); + expect( + value.mapOccurrence(DateTime.utc(2026, 3, 16, allDay ? 0 : 9)), + DateTime.utc(2026, 3, 17, allDay ? 0 : 9), + ); + } + }); + test( + 'time-only edits retain an accepted negative month-day rule verbatim', + () { + final value = plan( + 'FREQ=MONTHLY;BYMONTHDAY=-1;COUNT=3', + DateTime.utc(2026, 1, 31, 9), + DateTime.utc(2026, 2, 28, 9), + DateTime.utc(2026, 2, 28, 11), + ); + expect(value.rule.isSupported, isFalse); + expect(value.encode(['RRULE:FREQ=MONTHLY;BYMONTHDAY=-1;COUNT=3']), [ + 'RRULE:FREQ=MONTHLY;BYMONTHDAY=-1;COUNT=3', + ]); + expect( + value.mapOccurrence(DateTime.utc(2026, 3, 31, 9)), + DateTime.utc(2026, 3, 31, 11), + ); + }, + ); + test( + 'multi-date monthly proposal maps selectors without swapping exceptions', + () { + final value = plan( + 'FREQ=MONTHLY;BYMONTHDAY=5,20;COUNT=6', + DateTime.utc(2026, 1, 5, 9), + DateTime.utc(2026, 2, 20, 9), + DateTime.utc(2026, 2, 2, 9), + ); + expect(value.requiresConfirmation, isTrue); + expect(value.rule.byMonthDay, unorderedEquals([5, 2])); + expect( + value.mapOccurrence(DateTime.utc(2026, 3, 20, 9)), + DateTime.utc(2026, 3, 2, 9), + ); + expect( + value.mapOccurrence(DateTime.utc(2026, 3, 5, 9)), + DateTime.utc(2026, 3, 5, 9), + ); + }, + ); + for (final exclusion in [ + 'EXDATE:20260615T160000Z', + 'EXDATE;TZID=America/Vancouver:20260615T090000', + 'EXDATE;VALUE=DATE:20260615', + ]) { + test( + 'cancellation identity reanchors without flattening RDATE ($exclusion)', + () { + final allDay = exclusion.contains('VALUE=DATE'); + final value = plan( + 'FREQ=WEEKLY;BYDAY=MO;COUNT=5', + DateTime.utc(2026, 6, 1, allDay ? 0 : 9), + DateTime.utc(2026, 6, 8, allDay ? 0 : 9), + DateTime.utc(2026, 6, 9, allDay ? 0 : 9), + allDay: allDay, + zone: allDay ? null : 'America/Vancouver', + ); + const extra = 'RDATE;VALUE=PERIOD:20260705T160000Z/PT3H'; + final encoded = (value.encode([ + 'RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=5', + exclusion, + extra, + ]) as List).cast(); + expect(encoded, contains(extra)); + final actual = encoded.singleWhere((line) => line.startsWith('EXDATE')); + expect( + actual, + contains( + allDay + ? '20260616' + : exclusion.contains('TZID') + ? '20260616T090000' + : '20260616T160000Z', + ), + ); + expect(actual.contains('TZID'), exclusion.contains('TZID')); + }, + ); + } +} + +List _expand(SeriesTimingChange plan, DateTime end) { + String wire(DateTime value) => value + .toIso8601String() + .replaceAll(RegExp('[-:]'), '') + .replaceAll('.000', ''); + final start = plan.masterStart; + final document = IcalSemanticDocument.parse( + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:fixture\r\n' + 'DTSTART:${wire(start)}\r\nDTEND:${wire(start.add(const Duration(hours: 1)))}\r\n' + 'RRULE:${plan.rule.toRrule()}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n', + ); + return IcalRecurrenceExpander() + .expand( + document, + rangeStartUtc: start.subtract(const Duration(days: 1)), + rangeEndUtc: end, + ) + .map((occurrence) => occurrence.start.localValue) + .toList(); +} diff --git a/test/features/schedule/presentation/schedule_ongoing_event_test.dart b/test/features/schedule/presentation/schedule_ongoing_event_test.dart new file mode 100644 index 00000000..526c5aa2 --- /dev/null +++ b/test/features/schedule/presentation/schedule_ongoing_event_test.dart @@ -0,0 +1,67 @@ +import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + setUpAll(initializeDateFormatting); + for (final allDay in [false, true]) { + for (final month in [false, true]) { + testWidgets( + '${month ? 'month' : 'ordinary agenda'} keeps an ongoing ${allDay ? 'all-day' : 'timed'} event', + (tester) async { + final range = ScheduleRange( + start: DateTime(2026, 6, 1), + end: DateTime(2026, 6, 8), + ); + final event = CalendarScheduleItem( + id: 'ongoing', + accountId: 'account', + provider: BusyProvider.google, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: 'Ongoing event', + allDay: allDay, + start: DateTime(2026, 5, 31, allDay ? 0 : 22), + end: DateTime(2026, 6, 3), + ); + ScheduleItem? opened; + final view = month + ? ScheduleMonthView( + range: range, + selectedDate: DateTime(2026, 6, 1), + firstWeekday: DateTime.monday, + items: [event], + onDaySelected: (_) {}, + onCreateAtDay: (_, {anchorContext}) {}, + onItemSelected: (_, item, [_]) => opened = item, + onTaskCompletionChanged: (_, _) {}, + ) + : ScheduleAgendaView( + range: range, + items: [event], + onItemSelected: (_, item, [_]) => opened = item, + onTaskCompletionChanged: (_, _) {}, + ); + await tester.pumpWidget( + localizedTestApp(child: Scaffold(body: view)), + ); + await tester.pumpAndSettle(); + expect(find.text('Ongoing event'), findsNWidgets(month ? 2 : 1)); + await tester.tap(find.text('Ongoing event').first); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + expect(opened, same(event)); + expect(opened!.start, event.start); + expect(opened!.end, DateTime(2026, 6, 3)); + }, + ); + } + } +} diff --git a/test/features/settings/presentation/account_settings_feedback_test.dart b/test/features/settings/presentation/account_settings_feedback_test.dart new file mode 100644 index 00000000..964a0962 --- /dev/null +++ b/test/features/settings/presentation/account_settings_feedback_test.dart @@ -0,0 +1,148 @@ +import 'package:busymax/l10n/generated/app_localizations_en.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; +import 'package:busymax/src/google_calendar/google_calendar_errors.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; +import 'package:busymax/src/dav/dav_errors.dart'; +import 'package:busymax/src/dav/sync/dav_account_sync_engine.dart'; +import 'package:busymax/src/features/settings/presentation/account_settings_feedback.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final l10n = AppLocalizationsEn(); + test( + 'typed DAV failures map to usable recovery without provider diagnostics', + () { + for (final (kind, code, recovery) in [ + ( + DavErrorKind.network, + 'DavNetworkFailure', + AccountSettingsRecovery.retry, + ), + ( + DavErrorKind.timeout, + 'DavRequestTimeout', + AccountSettingsRecovery.retry, + ), + ( + DavErrorKind.authentication, + 'DavCredentialsRevoked', + AccountSettingsRecovery.reconnect, + ), + ( + DavErrorKind.authorization, + 'DavPermissionDenied', + AccountSettingsRecovery.revalidate, + ), + ( + DavErrorKind.protocol, + 'DavUnsupportedServer', + AccountSettingsRecovery.revalidate, + ), + ]) { + final error = DavException( + kind: kind, + code: code, + safeMessage: 'private provider body', + correlationId: 'private-correlation', + ); + for (final failure in [ + error, + DavAccountSyncException([error]), + AuthorizationScopedFailure( + accountId: 'qa', + generation: 2, + cause: error, + ), + ]) { + final feedback = AccountSettingsFeedback.fromError(l10n, failure); + expect(feedback.recovery, recovery); + expect(feedback.message, isNot(contains('private'))); + expect(feedback.message, isNot(contains('Dav'))); + if (kind == DavErrorKind.network || kind == DavErrorKind.timeout) { + expect(feedback.message, l10n.accountServiceUnreachable); + expect( + feedback.message.toLowerCase(), + isNot(contains('reconnect')), + ); + } + } + expect( + AccountSettingsFeedback.fromDavCode(l10n, code).recovery, + recovery, + ); + } + }, + ); + test( + 'provider service failures and throttling offer retry without raw messages', + () { + for (final error in [ + const GoogleCalendarApiError( + statusCode: 503, + code: 'private', + message: 'private', + ), + const MicrosoftCalendarApiError( + statusCode: 502, + code: 'private', + message: 'private', + ), + ]) { + final result = AccountSettingsFeedback.fromError(l10n, error); + expect(result.message, l10n.oauthProviderTemporaryFailure); + expect(result.recovery, AccountSettingsRecovery.retry); + } + expect( + AccountSettingsFeedback.fromError( + l10n, + const GoogleCalendarApiError( + statusCode: 403, + code: 'private', + message: 'private', + reasons: ['rateLimitExceeded'], + ), + ).message, + l10n.oauthProviderThrottled, + ); + }, + ); + test('unknown failures and persisted codes never become UI strings', () { + for (final feedback in [ + AccountSettingsFeedback.fromError( + l10n, + StateError('private/token/example'), + ), + AccountSettingsFeedback.fromDavCode(l10n, 'private/token/example'), + ]) { + expect(feedback.message, l10n.accountSyncProblem); + expect(feedback.recovery, AccountSettingsRecovery.retry); + } + }); + test( + 'registration recovery differs from an expired grant or a network outage', + () { + expect( + AccountSettingsFeedback.fromError( + l10n, + const OAuthException('OAuthRegistrationUnresolved', 'private'), + ).recovery, + AccountSettingsRecovery.replaceRegistration, + ); + expect( + AccountSettingsFeedback.fromError( + l10n, + const OAuthException('OAuthMissingToken', 'private'), + ).recovery, + AccountSettingsRecovery.reconnect, + ); + expect( + AccountSettingsFeedback.fromError( + l10n, + const OAuthException('OAuthTemporaryTransport', 'private'), + ).recovery, + AccountSettingsRecovery.retry, + ); + }, + ); +} diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 6dd4fe5a..c97620f5 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1,18 +1,32 @@ import '../../../support/desktop_registration_config.dart'; import '../../../support/contacts_fixture.dart'; +import '../../../support/dav_contacts_fixture.dart' as carddav; +import '../../../dav/nextcloud/nextcloud_admin_fixture.dart'; import '../../../platform/contacts_settings_routes_test.dart' show complete, settle; import '../../../core/auth/authorization_transition_test.dart' show Harness; import 'package:busymax/src/core/auth/oauth_registration.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/auth/authorization_persistence.dart'; +import 'package:busymax/src/contacts/busymax_contacts_controller.dart'; +import 'package:busystack_contacts/busystack_contacts.dart'; +import 'package:busymax/src/dav/discovery/dav_discovery_models.dart'; import 'dart:async'; + +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/calendar_providers/calendar_colors.dart'; +import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; +import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; + import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -28,12 +42,15 @@ import 'package:busymax/src/dav/auth/dav_account_onboarding_service.dart'; import 'package:busymax/src/dav/auth/nextcloud_login_flow_v2.dart'; import 'package:busymax/src/dav/dav_errors.dart'; import 'package:busymax/src/dav/storage/dav_settings_repository.dart'; +import 'package:busymax/src/dav/nextcloud/nextcloud_collection_service.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/accounts/domain/account_connection_state.dart'; import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_loopback_flow.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; @@ -47,6 +64,8 @@ import 'package:busymax/src/webcal/webcal_http_client.dart'; import 'package:busymax/src/webcal/webcal_subscription_service.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:drift/native.dart'; +import 'package:drift/drift.dart' + show QueryInterceptor, QueryExecutor, ApplyInterceptor; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; @@ -60,6 +79,1332 @@ import 'package:busymax/src/platform/common/desktop_services.dart'; const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); void main() { + registerContactsOverviewRegressions(); + for (final sharedFailure in [false, true]) { + testWidgets( + 'temporary account status preserves distinct service recovery; shared=$sharedFailure', + (tester) async { + const account = AccountEntity( + id: 'google:g', + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: 'g', + authState: accountAuthStateTemporarilyUnavailable, + ); + final container = _container( + selectedAccountId: account.id, + authRepository: _FakeAuthRepository(), + accounts: const [account], + ); + addTearDown(container.dispose); + final activity = container.read(accountSyncActivityProvider); + final network = http.ClientException('private provider body'); + for (final domain in ['calendar', 'tasks']) { + await expectLater( + activity.run(account.id, domain, () async { + throw domain == 'calendar' || sharedFailure + ? network + : const OAuthException( + 'OAuthMissingRequiredScope', + 'private', + ); + }), + throwsA(isNotNull), + ); + } + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1000), + ); + if (sharedFailure) { + expect( + find.byKey(const ValueKey('account-primary-feedback-google:g')), + findsOneWidget, + ); + expect( + find.text('Could not reach the service').hitTestable(), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('account-domain-feedback-calendar-google:g'), + ), + findsNothing, + ); + } else { + expect( + find.byKey(const ValueKey('account-primary-feedback-google:g')), + findsNothing, + ); + expect( + find.byKey( + const ValueKey('account-domain-feedback-calendar-google:g'), + ), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('account-domain-feedback-tasks-google:g'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey( + const ValueKey('account-domain-recovery-calendar-google:g'), + ), + matching: find.text('Retry'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey( + const ValueKey('account-domain-recovery-tasks-google:g'), + ), + matching: find.text('Reconnect this account'), + ), + findsOneWidget, + ); + } + expect(find.textContaining('private'), findsNothing); + }, + ); + } + + testWidgets( + 'late recovery-route lookup cannot replace a newer Settings category', + (tester) async { + final repository = _DelayedLookupAccountsRepository(const [ + _googleAccount, + ]); + final container = _container( + selectedAccountId: _googleAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + accountsRepository: repository, + ); + addTearDown(container.dispose); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: localizedTestApp( + child: const SettingsScreen( + initialPage: SettingsPage.accounts, + reconnectAccountId: 'google:g', + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('settings-navigation-schedule')), + ); + await tester.pumpAndSettle(); + repository.lookup.complete(_googleAccount); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-header-title')), + ) + .text, + 'Schedule', + ); + expect( + find.byKey(const ValueKey('account-detail-google:g')), + findsNothing, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Accounts overview opens stable identities and Back restores scroll, focus and category navigation', + (tester) async { + const secondCloud = AccountEntity( + id: 'cloud-two', + provider: BusyProvider.nextcloud, + authority: 'https://second.example.test/team', + providerAccountId: 'alex', + displayName: 'Nextcloud User', + credentialKind: CredentialKind.nextcloudAppPassword, + authState: accountAuthStateSignedIn, + ); + final container = _container( + selectedAccountId: _googleAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [ + _googleAccount, + _microsoftAccount, + _nextcloudAccount, + secondCloud, + ], + buildConfig: _configuredBuildConfig, + davCollections: [ + _davCollection( + id: 'one-collection', + name: 'Only first server', + supportsEvents: true, + ), + ], + ); + addTearDown(container.dispose); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 700), + openSelectedAccount: false, + ); + expect( + find.byKey(const ValueKey('account-entry-nextcloud:n')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('account-entry-cloud-two')), + findsOneWidget, + ); + expect(find.text('Only first server'), findsNothing); + expect(find.text('Add Google account'), findsOneWidget); + final entry = find.byKey(const ValueKey('account-entry-cloud-two')); + await tester.ensureVisible(entry); + final scroll = tester + .widget(find.byType(SingleChildScrollView)) + .controller!; + final offset = scroll.offset; + await tester.tap(entry); + await settle(tester); + expect( + find.byKey(const ValueKey('account-detail-cloud-two')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('remove-account-cloud-two')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('remove-account-nextcloud:n')), + findsNothing, + ); + expect(find.text('Only first server'), findsNothing); + expect(find.textContaining(secondCloud.authority), findsOneWidget); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-navigation-accounts')), + ) + .selected, + isTrue, + ); + await tester.tap( + find.byKey(const ValueKey('settings-header-back-button')), + ); + await tester.pumpAndSettle(); + expect(scroll.offset, closeTo(offset, 0.5)); + expect( + FocusManager.instance.primaryFocus?.debugLabel, + 'account-entry-account:cloud-two', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('account-detail-cloud-two')), + findsOneWidget, + ); + final sync = Completer(); + final running = container + .read(accountSyncActivityProvider) + .run(_nextcloudAccount.id, 'dav', () => sync.future); + await tester.tap( + find.byKey(const ValueKey('settings-navigation-schedule')), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('account-detail-cloud-two')), + findsNothing, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-header-title')), + ) + .text, + 'Schedule', + ); + expect( + container + .read(accountSyncActivityProvider) + .isRunning(_nextcloudAccount.id, 'dav'), + isTrue, + ); + sync.complete(); + await running; + await tester.tap( + find.byKey(const ValueKey('settings-navigation-accounts')), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('account-entry-cloud-two')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-header-title')), + ) + .text, + 'Accounts', + ); + await _disposeDiagnosticsWidget(tester); + }, + ); + + for (final failRemoval in [false, true]) { + testWidgets( + 'account list changes and late removal stay bound to the original ID; failure=$failRemoval', + (tester) async { + final changes = StreamController>(); + changes.add(const [_googleAccount, _microsoftAccount]); + final removal = Completer(); + final auth = _FakeAuthRepository()..removalCompleter = removal; + final container = _container( + selectedAccountId: _googleAccount.id, + authRepository: auth, + accounts: const [_googleAccount, _microsoftAccount], + managedAccountStream: changes.stream, + ); + addTearDown(() async { + container.dispose(); + await changes.close(); + }); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 900), + openSelectedAccount: false, + ); + await tester.tap(find.byKey(const ValueKey('account-entry-google:g'))); + await settle(tester); + changes.add(const [_microsoftAccount, _googleAccount]); + await settle(tester); + expect( + find.byKey(const ValueKey('account-detail-google:g')), + findsOneWidget, + ); + await _openAccountRemovalDialog(tester); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pumpAndSettle(); + expect(auth.removalCalls.single.accountId, _googleAccount.id); + await tester.tap( + find.byKey(const ValueKey('settings-header-back-button')), + ); + await tester.pumpAndSettle(); + final other = find.byKey(const ValueKey('account-entry-microsoft:m')); + await tester.ensureVisible(other); + await tester.tap(other); + await tester.pumpAndSettle(); + if (failRemoval) { + removal.completeError(StateError('private deletion diagnostic')); + } else { + changes.add(const [_microsoftAccount]); + removal.complete( + const AccountRemovalResult( + authorizationRevocationStatus: + AccountAuthorizationRevocationStatus.notRequested, + ), + ); + } + await settle(tester); + expect( + find.byKey(const ValueKey('account-detail-microsoft:m')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('remove-account-microsoft:m')), + findsOneWidget, + ); + expect(auth.removalCalls, hasLength(1)); + expect(find.textContaining('private deletion'), findsNothing); + expect( + find.text('Could not finish removing the account. Try again.'), + findsNothing, + ); + await tester.tap( + find.byKey(const ValueKey('settings-header-back-button')), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('account-entry-google:g')), + failRemoval ? findsOneWidget : findsNothing, + ); + expect( + find.byKey(const ValueKey('account-entry-microsoft:m')), + findsOneWidget, + ); + await _disposeDiagnosticsWidget(tester); + }, + ); + } + + for (final enabled in [true, false]) { + testWidgets( + 'ordinary Nextcloud account Reconnect renews linked request credentials; suggestions enabled=$enabled', + (tester) async { + final database = AppDatabase.memoryForTests(); + final secrets = InMemorySecretStore(); + final persistence = AuthorizationPersistence( + database: database, + secrets: secrets, + ); + final f = ContactsFixture( + database, + realDav: true, + readAuthorizationGeneration: persistence.generation, + readLinkedDavCredential: (id) async { + final credential = await persistence.readCurrentCredential(id); + return credential is NextcloudSecretRecord + ? BusyMaxLinkedDavCredential( + server: credential.canonicalServer, + username: credential.loginName, + password: credential.appPassword, + ) + : null; + }, + ); + addTearDown(() => complete(tester, f.close)); + var polls = 0, browsers = 0; + final service = DavAccountOnboardingService( + database: database, + secretStore: secrets, + authorizationPersistence: persistence, + nextcloudLoginFlow: NextcloudLoginFlowV2( + client: MockClient((request) async { + return http.Response( + jsonEncode( + request.url.path.endsWith('/poll') + ? { + 'server': carddav.server.toString(), + 'loginName': 'alex', + 'appPassword': ++polls == 1 + ? 'password-A' + : 'password-B', + } + : { + 'poll': { + 'token': 'synthetic', + 'endpoint': carddav.server + .resolve('login/v2/poll') + .toString(), + }, + 'login': carddav.server + .resolve('login/v2/browser') + .toString(), + }, + ), + 200, + ); + }), + browserLauncher: (_) async { + browsers++; + return true; + }, + delay: (_) async {}, + ), + idFactory: () => 'ordinary-reconnect', + discover: + ({ + required accountId, + required provider, + required accountAuthority, + required credential, + cancellationToken, + }) async => DavDiscoveryResult( + accountId: accountId, + provider: provider, + service: DavServiceDiscovery( + canonicalServiceUri: accountAuthority, + canonicalOrigin: accountAuthority.replace(path: ''), + principalHref: accountAuthority.resolve('principals/alex/'), + calendarHomeHref: accountAuthority.resolve('calendars/alex/'), + calendarUserAddresses: const [], + scheduleInboxHref: null, + scheduleOutboxHref: null, + capabilities: const AccountServiceCapabilities( + hasPrincipal: true, + hasCalendarHome: true, + ), + discoveredAtUtc: DateTime.utc(2026, 10, 21), + lastValidatedAtUtc: DateTime.utc(2026, 10, 21), + providerProfileVersion: 1, + ), + collections: const [], + ), + ); + final remote = carddav.DavFixture(); + var revokedA = false; + f.respondDav = (request) async { + if (revokedA && + request.headers['authorization'] == + 'Basic ${base64Encode(utf8.encode('alex:password-A'))}') { + return http.Response('', 401); + } + return remote.respond(request); + }; + final parent = (await complete( + tester, + () => service.connectNextcloud( + enteredServer: carddav.server.toString(), + ), + )).accountId; + final contactId = BusyMaxContactsController.linkedContactAccountId( + parent, + ); + Object? revokedFailure; + await complete(tester, () async { + await f.controller.enableLinkedNextcloudContacts(parent); + await f.controller.synchronizeAccount(contactId); + final source = (await f.controller.sourceSettings()).first.source; + await f.store.setSourceEnabled(source.key, enabled: false); + revokedA = true; + try { + await f.controller.synchronizeAccount(contactId); + } on Object catch (error) { + revokedFailure = error; + } + if (!enabled) await f.controller.disableContactsAccount(contactId); + }); + expect(revokedFailure, isA()); + final before = await complete( + tester, + () => f.store.read((tx) => tx.account(contactId)), + ); + final preferences = await complete(tester, f.controller.sourceSettings); + final accounts = await complete(tester, f.accounts.listVisibleAccounts); + final connected = Completer(); + final container = _container( + selectedAccountId: parent, + authRepository: AuthRepository( + database: database, + accountsRepository: f.accounts, + authorizationPersistence: persistence, + oAuth: OAuthService( + config: _emptyBuildConfig, + tokenStore: secrets, + persistence: persistence, + loopbackFlow: OAuthLoopbackFlow(), + httpClient: MockClient( + (_) async => + throw StateError('Nextcloud reconnect must not use Google'), + ), + ), + ), + accounts: accounts, + accountsRepository: f.accounts, + contactsFixture: f, + davOnboardingService: service, + signedInSyncRunner: (_, _) async { + if (!connected.isCompleted) connected.complete(); + }, + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + }); + expect( + identical( + container.read(davAccountOnboardingServiceProvider), + service, + ), + isTrue, + ); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1400), + ); + await settle(tester); + await _expandAccountAdvanced(tester, parent); + final reconnect = find.byKey(ValueKey('reconnect-account-$parent')); + await tester.ensureVisible(reconnect); + await tester.pumpAndSettle(); + await tester.tap(reconnect); + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.byKey(const Key('nextcloud-server-field')), + ) + .controller! + .text, + accounts.single.authority, + ); + await tester.tap(find.text('Connect').last); + await tester.pumpAndSettle(); + await complete(tester, () => connected.future); + await settle(tester); + expect(polls, 2); + expect(browsers, 2); + expect(container.read(authSessionControllerProvider).accountId, parent); + expect( + (await complete( + tester, + () => persistence.readCurrentCredential(parent), + ) as NextcloudSecretRecord).appPassword, + 'password-B', + ); + final requestCount = f.requests.length; + if (enabled) { + await complete( + tester, + () => f.controller.synchronizeAccount(contactId), + ); + final requests = f.requests.skip(requestCount).toList(); + expect(requests, isNotEmpty); + expect( + requests.every( + (r) => + r.headers['authorization'] == + 'Basic ${base64Encode(utf8.encode('alex:password-B'))}', + ), + isTrue, + ); + } + final after = await complete( + tester, + () => f.store.read((tx) => tx.account(contactId)), + ); + expect(after!.enabled, enabled); + expect(after.generation, before!.generation); + for (final preference in preferences) { + expect( + await complete( + tester, + () => f.store.sourceEnabled(preference.source.key), + ), + preference.enabled, + ); + } + expect(browsers, 2); + await _disposeDiagnosticsWidget(tester); + await complete(tester, f.controller.close); + }, + ); + } + + testWidgets( + 'successful empty Nextcloud discovery is distinct from undiscovered and failed discovery', + (tester) async { + final f = ContactsFixture(AppDatabase.memoryForTests()); + await tester.runAsync( + () => f.accounts.upsertSignedInAccount( + id: 'nextcloud', + provider: BusyProvider.nextcloud, + providerAccountId: 'qa', + authority: 'https://cloud.example.test', + grantedScopes: '', + ), + ); + final accounts = (await tester.runAsync(f.accounts.listVisibleAccounts))!; + final container = _container( + selectedAccountId: 'nextcloud', + authRepository: _FakeAuthRepository(), + accounts: accounts, + contactsFixture: f, + accountsRepository: f.accounts, + ); + addTearDown(() async { + container.dispose(); + await tester.runAsync(f.close); + }); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1400), + ); + await settle(tester); + expect( + find.text( + 'Discovery has not completed. Refresh or reconnect this account.', + ), + findsOneWidget, + ); + await tester.runAsync( + () => f.database + .into(f.database.davAccountServices) + .insert( + DavAccountServicesCompanion.insert( + accountId: 'nextcloud', + canonicalServiceUri: + 'https://cloud.example.test/remote.php/dav/', + canonicalOrigin: 'https://cloud.example.test', + discoveredAtUtc: '2026-10-20T12:00:00Z', + ), + ), + ); + await settle(tester); + expect(find.text('No collections found.'), findsOneWidget); + // Inventory discovery is not an invented Calendar or Tasks sync time. + for (final domain in ['calendar', 'tasks']) { + expect( + tester + .widget( + find.byKey(ValueKey('account-sync-$domain-nextcloud')), + ) + .subtitle, + 'No successful sync time recorded', + ); + } + await tester.runAsync( + () => f.database.customUpdate( + "UPDATE dav_account_services SET last_discovery_error_code = 'DavNetworkFailure' WHERE account_id = 'nextcloud'", + updates: {f.database.davAccountServices}, + ), + ); + await settle(tester); + expect(find.text('No collections found.'), findsNothing); + expect(find.text('Could not reach the service'), findsOneWidget); + expect(tester.takeException(), isNull); + await _disposeDiagnosticsWidget(tester); + }, + ); + + for (final combinedColorChange in [false, true]) { + testWidgets( + 'Google local selection and pending/failed CalendarList.hidden changes stay distinct; combined color=$combinedColorChange', + (tester) async { + final f = ContactsFixture(AppDatabase.memoryForTests()); + await tester.runAsync(() => f.addParent(BusyProvider.google)); + final repository = CalendarRepository(database: f.database); + late PendingOp otherBlocked; + Future> targetOperations() => (f.database.select( + f.database.pendingOps, + )..where((row) => row.accountId.equals('google'))).get(); + await tester.runAsync(() async { + for (final primary in [false, true]) { + await repository.upsertSource( + accountId: 'google', + source: CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: primary ? 'primary' : 'qa', + summary: primary ? 'QA Primary' : 'QA Calendar', + primaryCalendar: primary, + backgroundColor: '#9a9cff', + foregroundColor: '#000000', + colorId: '1', + rawJson: { + 'hidden': false, + 'backgroundColor': '#9a9cff', + 'foregroundColor': '#000000', + 'colorId': '1', + }, + ), + ); + } + await f.accounts.upsertSignedInAccount( + id: 'other-google', + provider: BusyProvider.google, + providerAccountId: 'other-subject', + grantedScopes: 'calendar tasks', + ); + await repository.upsertSource( + accountId: 'other-google', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'other-qa', + summary: 'Other account calendar', + rawJson: {'hidden': false}, + ), + ); + final otherId = CalendarRepository.sourceId( + accountId: 'other-google', + provider: BusyProvider.google, + providerCalendarId: 'other-qa', + ); + await repository.setSourceProviderHidden(otherId, true); + final otherOp = await f.database + .select(f.database.pendingOps) + .getSingle(); + await f.database.pendingOpsDao.updateAttempt( + id: otherOp.id, + attemptCount: 1, + nextAttemptAtUtc: DateTime.utc(9999, 12, 31), + lastErrorCode: 'forbidden', + ); + otherBlocked = (await f.database.pendingOpsDao.getOp(otherOp.id))!; + }); + final accounts = (await tester.runAsync( + f.accounts.listVisibleAccounts, + ))!; + late final PendingOpResolutionService visibilityResolution; + final container = _container( + selectedAccountId: 'google', + authRepository: _FakeAuthRepository(), + accounts: accounts, + contactsFixture: f, + accountsRepository: f.accounts, + calendarSourceStream: repository.watchSourcesForAccounts([ + 'google', + 'other-google', + ]), + calendarRepository: repository, + operationResolution: (id) { + expect(id, 'google'); + return visibilityResolution; + }, + ); + addTearDown(() async { + container.dispose(); + await tester.runAsync(f.close); + }); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1400), + ); + await settle(tester); + final sources = container + .read(calendarSourcesStreamProvider) + .requireValue; + final ordinary = sources.singleWhere( + (s) => s.accountId == 'google' && !s.primaryCalendar, + ); + final primary = sources.singleWhere((s) => s.primaryCalendar); + await _expandCalendar(tester, ordinary.id); + final local = find.byKey( + ValueKey('settings-calendar-schedule-${ordinary.id}'), + ); + await tester.ensureVisible(local); + await tester.tap(local); + await settle(tester); + expect( + (await complete( + tester, + () => f.database.select(f.database.calendarSources).get(), + )).singleWhere((s) => s.id == ordinary.id).selected, + isFalse, + ); + expect(await complete(tester, targetOperations), isEmpty); + await complete( + tester, + () => repository.setSourceProviderHidden(ordinary.id, true), + ); + await settle(tester); + expect(find.text('Google visibility change pending'), findsOneWidget); + final requests = []; + var denied = true; + final client = GoogleCalendarApiClient( + baseUri: Uri.parse('https://www.googleapis.com'), + httpClient: MockClient((request) async { + requests.add(request); + return denied + ? http.Response( + '{"error":{"code":403,"message":"denied"}}', + 403, + ) + : http.Response( + jsonEncode({ + 'id': 'qa', + 'summary': 'QA Calendar', + 'backgroundColor': '#9a9cff', + 'foregroundColor': '#000000', + 'colorId': '1', + 'hidden': jsonDecode(request.body)['hidden'], + }), + 200, + ); + }), + ); + final engine = CalendarSyncEngine( + database: f.database, + client: client, + accountId: 'google', + ); + visibilityResolution = PendingOpResolutionService( + database: f.database, + accountId: 'google', + syncTasks: () async => fail('Visibility must not synchronize Tasks'), + syncCalendar: engine.dispatchPendingWrites, + ); + await complete( + tester, + () => CalendarSyncEngine( + database: f.database, + client: client, + accountId: 'google', + ).dispatchPendingWrites(), + ); + await settle(tester); + expect(requests.single.method, 'PATCH'); + expect(requests.single.url.path, contains('/users/me/calendarList/qa')); + expect(jsonDecode(requests.single.body)['hidden'], isTrue); + expect( + find.text( + 'Google visibility change failed. Google’s last confirmed state is shown. BusyMax still follows the pending change. Retry or discard it.', + ), + findsWidgets, + ); + final remote = tester.widget( + find.byKey(ValueKey('settings-calendar-provider-${ordinary.id}')), + ); + expect(remote.value, isTrue); + // Ordinary sync cannot replay a permanent permission rejection. + await complete(tester, engine.dispatchPendingWrites); + expect(requests, hasLength(1)); + final retry = find.byKey( + ValueKey('settings-calendar-visibility-retry-${ordinary.id}'), + ); + expect(retry, findsOneWidget); + denied = false; + await tester.ensureVisible(retry); + await tester.tap(retry); + await settle(tester); + expect(requests, hasLength(2)); + expect(jsonDecode(requests.last.body)['hidden'], isTrue); + expect(await complete(tester, targetOperations), isEmpty); + + // A second rejected hide can instead be discarded locally. This keeps + // the user's independent selection and does not send another PATCH. + final queuedBeforeFailure = await complete(tester, () async { + await repository.setSourceProviderHidden(ordinary.id, false); + await engine.dispatchPendingWrites(); + denied = true; + if (combinedColorChange) { + await repository.setSourceColor( + ordinary.id, + calendarColorChoices(BusyProvider.google).last, + ); + } + await repository.setSourceProviderHidden(ordinary.id, true); + final queued = (await targetOperations()).single; + await engine.dispatchPendingWrites(); + return queued; + }); + final patch = jsonDecode(queuedBeforeFailure.requestJson) as Map; + expect(patch['hidden'], isTrue); + expect(patch.containsKey('backgroundColor'), combinedColorChange); + expect(patch.containsKey('foregroundColor'), combinedColorChange); + await settle(tester); + final beforeDiscard = requests.length; + final blocked = (await complete(tester, targetOperations)).single; + final beforeRestore = (await complete( + tester, + () => f.database.select(f.database.calendarSources).get(), + )).singleWhere((s) => s.id == ordinary.id); + if (combinedColorChange) { + expect( + beforeRestore.backgroundColor, + isNot(ordinary.backgroundColor), + ); + } + final discard = find.byKey( + ValueKey('settings-calendar-visibility-discard-${ordinary.id}'), + ); + expect(discard, findsOneWidget); + await tester.ensureVisible(discard); + await tester.tap(discard); + await tester.pumpAndSettle(); + final confirmation = + 'Discard the pending changes to “QA Calendar”?\n\n' + 'These settings will return to their previous values:\n' + '${combinedColorChange ? '• Calendar color\n' : ''}' + '• Show in Google’s calendar list\n\n' + 'Your BusyMax calendar selection is kept. No changes will be sent to Google.'; + expect(find.text(confirmation), findsOneWidget); + expect(find.textContaining('Google Tasks'), findsNothing); + // Cancelling keeps the entire patch and its optimistic settings intact. + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect((await complete(tester, targetOperations)).single, blocked); + expect( + (await complete( + tester, + () => f.database.select(f.database.calendarSources).get(), + )).singleWhere((s) => s.id == ordinary.id), + beforeRestore, + ); + await tester.tap(discard); + await tester.pumpAndSettle(); + expect(find.text(confirmation), findsOneWidget); + await tester.tap(find.text('Discard').last); + await settle(tester); + expect(requests.length, beforeDiscard); + expect(await complete(tester, targetOperations), isEmpty); + expect(tester.widget(local).value, isFalse); + expect(tester.widget(local).onChanged, isNotNull); + await tester.ensureVisible(local); + await tester.tap(local); + await settle(tester); + final restored = (await complete( + tester, + () => f.database.select(f.database.calendarSources).get(), + )).singleWhere((s) => s.id == ordinary.id); + expect(restored.hidden, isFalse); + expect(restored.selected, isTrue); + expect(restored.backgroundColor, ordinary.backgroundColor); + expect(restored.foregroundColor, ordinary.foregroundColor); + expect(restored.colorId, ordinary.colorId); + expect( + await complete( + tester, + () => f.database.pendingOpsDao.getOp(otherBlocked.id), + ), + otherBlocked, + ); + await _expandCalendar(tester, primary.id); + final primarySwitch = tester.widget( + find.byKey(ValueKey('settings-calendar-provider-${primary.id}')), + ); + expect(primarySwitch.value, isTrue); + expect(primarySwitch.onChanged, isNull); + await complete( + tester, + () => expectLater( + repository.setSourceProviderHidden(primary.id, true), + throwsA(isA()), + ), + ); + expect( + find.text('Google’s primary calendar stays in its calendar list.'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + await _disposeDiagnosticsWidget(tester); + }, + ); + } + + testWidgets( + 'Calendar and Tasks show loading, recorded per-service times, empty success and cached errors truthfully', + (tester) async { + final f = ContactsFixture(AppDatabase.memoryForTests()); + await tester.runAsync(() => f.addParent(BusyProvider.google)); + final accounts = (await tester.runAsync(f.accounts.listVisibleAccounts))!; + final sources = StreamController>(); + final container = _container( + selectedAccountId: 'google', + authRepository: _FakeAuthRepository(), + accounts: accounts, + contactsFixture: f, + accountsRepository: f.accounts, + calendarSourceStream: sources.stream, + ); + addTearDown(() async { + container.dispose(); + await sources.close(); + await tester.runAsync(f.close); + }); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1400), + ); + await settle(tester); + expect(find.text('Loading collections…'), findsWidgets); + expect(find.text('No calendars synced yet.'), findsNothing); + sources.add([]); + await settle(tester); + expect( + find.text( + 'Discovery has not completed. Refresh or reconnect this account.', + ), + findsOneWidget, + ); + await complete( + tester, + () => f.database.customUpdate( + "INSERT INTO domain_sync_schedules(account_id,domain,last_successful_pull_utc) VALUES ('google','tasks','2026-10-19T12:00:00Z')", + updates: {f.database.domainSyncSchedules}, + ), + ); + await settle(tester); + final calendarStatus = tester.widget( + find.byKey(const ValueKey('account-sync-calendar-google')), + ); + final tasksStatus = tester.widget( + find.byKey(const ValueKey('account-sync-tasks-google')), + ); + expect(calendarStatus.subtitle, 'No successful sync time recorded'); + expect(tasksStatus.subtitle, contains('Last successful sync:')); + final activity = container.read(accountSyncActivityProvider); + final gate = Completer(); + final syncing = activity.run('google', 'calendar', () async { + await gate.future; + }); + await tester.pump(); + expect(find.text('Syncing…'), findsWidgets); + gate.complete(); + await syncing; + await complete( + tester, + () => f.database.customUpdate( + "INSERT INTO domain_sync_schedules(account_id,domain,last_successful_pull_utc) VALUES ('google','calendar','2026-10-20T12:00:00Z')", + updates: {f.database.domainSyncSchedules}, + ), + ); + await settle(tester); + expect(find.text('No collections found.'), findsOneWidget); + const cached = CalendarSourceEntity( + id: 'cached', + accountId: 'google', + provider: BusyProvider.google, + providerCalendarId: 'cached', + summary: 'Cached QA calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + sources.add([cached]); + await settle(tester); + await expectLater( + activity.run('google', 'calendar', () async { + throw const OAuthException('OAuthTemporaryTransport', 'synthetic'); + }), + throwsA(isA()), + ); + await settle(tester); + expect(find.text('Cached QA calendar'), findsOneWidget); + final failed = tester.widget( + find.byKey(const ValueKey('account-sync-calendar-google')), + ); + expect(failed.subtitle, contains('Last successful sync:')); + expect(failed.subtitle, isNot(tasksStatus.subtitle)); + expect(find.text('No collections found.'), findsNothing); + sources.addError( + StateError('synthetic cached collection stream failure'), + ); + await settle(tester); + expect(find.text('Cached QA calendar'), findsOneWidget); + expect( + find.text( + 'Collections could not be loaded. Cached data is retained; retry synchronization.', + ), + findsWidgets, + ); + expect(tester.takeException(), isNull); + await _disposeDiagnosticsWidget(tester); + }, + ); + + for (final summariesFail in [false, true]) { + testWidgets( + 'Google reconnect reuses issuing registration with ${summariesFail ? 'failed' : 'loading'} summaries and revoked grant', + (tester) async { + await tester.runAsync(() async { + final h = Harness(buildConfig: syntheticDesktopConfig()); + addTearDown(() async { + h.staging.dispose(); + await h.db.close(); + }); + await h.seed(); + await h.makeGoogleUserOwned(); + await h.repository.markReconnectRequired('opaque'); + final before = await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential; + final observing = _ObservedAuthRepository(h); + final container = _container( + selectedAccountId: 'opaque', + authRepository: observing, + accounts: await h.accounts.listVisibleAccounts(), + accountsRepository: h.accounts, + buildConfig: h.buildConfig, + productionHarness: h, + signedInSyncRunner: (_, _) async {}, + registrationSummaryStream: summariesFail + ? Stream.error(StateError('synthetic summaries failure')) + : const Stream.empty(), + ); + addTearDown(container.dispose); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1200), + ); + await _expandAccountAdvanced(tester, 'opaque'); + final reconnect = find.byKey( + const ValueKey('reconnect-account-opaque'), + ); + await tester.ensureVisible(reconnect); + await tester.tap(reconnect); + await tester.pump(); + await observing.started.future; + await observing.finished; + await tester.pumpAndSettle(); + expect(observing.lastRequest!.intent, AuthorizationIntent.reconnect); + expect(h.flow.clients, [before.registration.clientId]); + expect( + find.byKey(const ValueKey('registration-reconnect-account')), + findsNothing, + ); + expect((await h.accounts.accountById('opaque'))!.isSignedIn, isTrue); + await tester.pumpWidget(const SizedBox()); + }); + }, + ); + } + + testWidgets( + 'Microsoft missing binding exposes explicit replacement that recovers the selected account', + (tester) async { + await tester.runAsync(() async { + final h = Harness(buildConfig: syntheticDesktopConfig()); + addTearDown(() async { + h.staging.dispose(); + await h.db.close(); + }); + await h.seedMicrosoft(shared: false); + const id = 'microsoft:ms-user'; + await h.secrets.deleteCredential(id); + final observing = _ObservedAuthRepository(h); + final container = _container( + selectedAccountId: id, + authRepository: observing, + accounts: await h.accounts.listVisibleAccounts(), + accountsRepository: h.accounts, + buildConfig: h.buildConfig, + productionHarness: h, + signedInSyncRunner: (_, _) async {}, + ); + addTearDown(container.dispose); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1200), + ); + await _expandAccountAdvanced(tester, 'microsoft:ms-user'); + await tester.tap( + find.byKey(const ValueKey('reconnect-account-microsoft:ms-user')), + ); + await tester.pump(); + await observing.started.future; + await observing.finished; + await tester.pumpAndSettle(); + expect(h.flow.clients, isEmpty); + final recovery = find.byKey( + const ValueKey('replace-registration-recovery-microsoft:ms-user'), + ); + expect(recovery, findsOneWidget); + observing.started = Completer(); + await tester.ensureVisible(recovery); + await tester.tap(recovery); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('registration-busymax'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await observing.started.future; + await observing.finished; + await tester.pumpAndSettle(); + expect( + observing.lastRequest!.intent, + AuthorizationIntent.replaceRegistration, + ); + expect(observing.lastRequest!.accountId, id); + expect( + (await h.secrets.readCredential( + id, + ) as MicrosoftDesktopCredential).subject, + 'ms-user', + ); + expect(await h.db.select(h.db.accounts).get(), hasLength(1)); + await tester.pumpWidget(const SizedBox()); + }); + }, + ); + + testWidgets( + 'six connected accounts retain independent Add actions and target removal', + (tester) async { + final accounts = [ + for (final provider in [ + BusyProvider.google, + BusyProvider.microsoft, + BusyProvider.nextcloud, + ]) + for (var i = 1; i <= 2; i++) + AccountEntity( + id: '${provider.name}:$i', + provider: provider, + providerAccountId: provider == BusyProvider.nextcloud + ? 'alex' + : 'subject-$i', + authority: provider == BusyProvider.nextcloud + ? 'https://cloud$i.example.test' + : provider == BusyProvider.google + ? 'https://accounts.google.com' + : 'https://login.microsoftonline.com/common', + credentialKind: provider == BusyProvider.nextcloud + ? CredentialKind.nextcloudAppPassword + : CredentialKind.oauth, + authState: 'signed_in', + email: provider == BusyProvider.nextcloud + ? null + : 'qa$i@example.test', + displayName: + 'Quality Assurance $i with a deliberately long account name', + ), + ]; + final auth = _FakeAuthRepository(); + final container = _container( + selectedAccountId: 'google:2', + authRepository: auth, + accounts: accounts, + buildConfig: _configuredBuildConfig, + signedInSyncRunner: (_, _) async {}, + ); + addTearDown(container.dispose); + await _pumpSettings( + tester, + container, + logicalSize: const Size(640, 900), + openSelectedAccount: false, + ); + for (final label in [ + 'Add Google account', + 'Add Microsoft account', + 'Add Nextcloud account', + ]) { + final row = tester.widget( + find.ancestor( + of: find.text(label), + matching: find.byType(BusyMaxActionRow), + ), + ); + expect(row.onTap, isNotNull); + } + expect( + find.text( + 'Quality Assurance 1 with a deliberately long account name · alex · https://cloud1.example.test', + ), + findsOneWidget, + ); + expect( + find.text( + 'Quality Assurance 2 with a deliberately long account name · alex · https://cloud2.example.test', + ), + findsOneWidget, + ); + final entry = find.byKey(const ValueKey('account-entry-google:1')); + await tester.ensureVisible(entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + final remove = find.byKey(const ValueKey('remove-account-google:1')); + await tester.ensureVisible(remove); + await tester.tap(remove); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('confirm-account-removal'))); + await tester.pumpAndSettle(); + expect(auth.removalCalls.single.accountId, 'google:1'); + expect(container.read(selectedAccountIdProvider), 'google:2'); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( 'Linux contacts source switch immediately saves, filters and rolls back failed persistence', (tester) async { @@ -102,10 +1447,7 @@ void main() { find.text('Read-only contacts for attendee suggestions'), findsWidgets, ); - expect( - find.byKey(const ValueKey('reconnect-contacts-google')), - findsOneWidget, - ); + expect(find.text('Reconnect this account · Contacts'), findsOneWidget); final toggle = find.byKey(ValueKey('contact-source-${source.key}')); await tester.ensureVisible(toggle); await tester.tap(toggle); @@ -280,6 +1622,16 @@ void main() { } } setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window'), + (call) async => call.method == 'state' ? {} : null, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + (_) async => null, + ); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( _nativeMenuChannel, @@ -288,6 +1640,13 @@ void main() { }); tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('yaru_window'), null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + null, + ); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeMenuChannel, null); }); @@ -534,6 +1893,8 @@ void main() { await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); + await _expandCalendar(tester, 'visible'); + await _expandCalendar(tester, 'hidden'); expect(find.text('Visible calendar'), findsOneWidget); expect(find.text('Hidden calendar'), findsOneWidget); final visibleSchedule = tester.widget( @@ -556,37 +1917,8 @@ void main() { expect(hiddenProvider.onChanged, isNotNull); expect(visibleProvider.value, isTrue); expect(visibleProvider.onChanged, isNotNull); - expect(find.text('Visibility'), findsNothing); - expect( - tester - .getCenter( - find.byKey(const ValueKey('settings-calendar-column-schedule')), - ) - .dx, - closeTo( - tester - .getCenter( - find.byKey(const ValueKey('settings-calendar-schedule-visible')), - ) - .dx, - 0.01, - ), - ); - expect( - tester - .getCenter( - find.byKey(const ValueKey('settings-calendar-column-provider')), - ) - .dx, - closeTo( - tester - .getCenter( - find.byKey(const ValueKey('settings-calendar-provider-visible')), - ) - .dx, - 0.01, - ), - ); + expect(find.text('Show in BusyMax'), findsNWidgets(2)); + expect(find.text('Show in Google’s calendar list'), findsNWidgets(2)); final accountHeading = tester.widget( find.byKey(const ValueKey('settings-account-heading-google:g')), @@ -605,17 +1937,6 @@ void main() { expect(accountHeading.style?.color, foreground); expect(calendarHeading.style?.fontWeight, FontWeight.bold); expect(calendarHeading.style?.color, foreground); - expect( - tester.getTopLeft(find.text('Calendars')).dx, - closeTo( - tester - .getTopLeft( - find.byKey(const ValueKey('settings-account-heading-google:g')), - ) - .dx, - 0.01, - ), - ); }); testWidgets('Linux sharing entry opens owner ACL permissions', ( @@ -662,6 +1983,8 @@ void main() { ), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('account-entry-google:g'))); + await tester.pumpAndSettle(); final entry = find.text('Manage calendar sharing'); await tester.ensureVisible(entry); await tester.tap(entry); @@ -720,20 +2043,28 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 900), + openSelectedAccount: false, + ); final foreground = Theme.of(tester.element(find.text('Calendar import'))) .colorScheme .onSurface; - for (final label in [ - 'Google', - 'Calendar import', - 'Calendar subscriptions', - ]) { + for (final label in ['Calendar import', 'Calendar subscriptions']) { final heading = tester.widget(find.text(label)); expect(heading.style?.fontWeight, FontWeight.bold); expect(heading.style?.color, foreground); } + await tester.tap(find.byKey(const ValueKey('account-entry-google:g'))); + await tester.pumpAndSettle(); + final providerHeading = tester.widget( + find.byKey(const ValueKey('settings-account-heading-google:g')), + ); + expect(providerHeading.style?.fontWeight, FontWeight.bold); + expect(providerHeading.style?.color, foreground); await tester.tap( find.byKey(const ValueKey('settings-navigation-schedule')), ); @@ -829,7 +2160,7 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + await _pumpSettings(tester, container, openSelectedAccount: false); final titleRoot = find.byKey(const ValueKey('settings-header-title')); final title = tester.widget( @@ -965,6 +2296,7 @@ void main() { authRepository: auth, accounts: const [_googleAccount, _nextcloudAccount], davOnboardingService: onboarding, + databaseOverride: database, ); addTearDown(container.dispose); @@ -1183,6 +2515,8 @@ void main() { ), ); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('account-entry-google:g'))); + await tester.pumpAndSettle(); expect(find.text('Remove account…'), findsOneWidget); expect( @@ -1211,6 +2545,7 @@ void main() { expect(removeTitle.style?.color, isNot(theme.colorScheme.error)); expect(trash.color, theme.colorScheme.error); + await tester.ensureVisible(removeRowFinder); await tester.tap(find.text('Remove account…')); await tester.pumpAndSettle(); final confirm = tester.widget( @@ -1335,12 +2670,15 @@ void main() { await _pumpSettings(tester, container); - expect(find.text('Reconnect Google account'), findsWidgets); + expect( + find.byKey(const ValueKey('account-recovery-google:g')), + findsOneWidget, + ); expect( find.text( - 'Your Google account needs to be reconnected to resume calendar and task synchronization.', + 'This account needs to be reconnected to resume synchronization.', ), - findsOneWidget, + findsWidgets, ); expect(find.text('New task list'), findsNothing); expect(find.text('Remove account…'), findsOneWidget); @@ -1358,15 +2696,169 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + await _pumpSettings(tester, container, openSelectedAccount: false); + + expect(find.text('Add Google account'), findsOneWidget); + expect(find.text('Add Microsoft account'), findsOneWidget); + }); + + testWidgets( + 'DAV collection activation persists separate choices and settings never toggles selection', + (tester) async { + final f = NextcloudAdminFixture(); + await tester.runAsync(() async { + await f.seed(); + await f.database.customUpdate( + "UPDATE accounts SET auth_state = 'signed_in'", + ); + final mixed = await (f.database.select( + f.database.davCollections, + )..where((row) => row.id.equals('collection'))).getSingle(); + for (final (id, events) in [ + ('events-only', true), + ('tasks-only', false), + ]) { + await f.database + .into(f.database.davCollections) + .insert( + mixed.copyWith( + id: id, + hrefKey: '${NextcloudAdminFixture.home}$id/', + requestUri: + '${NextcloudAdminFixture.origin}${NextcloudAdminFixture.home}$id/', + displayName: events ? 'Appointments' : 'To do', + supportedComponentMask: events ? 1 : 2, + eventProjectionEnabled: events, + taskProjectionEnabled: !events, + ), + ); + } + await f.database + .into(f.database.accounts) + .insert( + AccountsCompanion.insert( + id: 'other-server', + provider: 'nextcloud', + authority: 'https://other.example.test', + providerAccountId: 'alex', + credentialKind: 'nextcloud_app_password', + createdAtUtc: mixed.createdAtUtc, + updatedAtUtc: mixed.updatedAtUtc, + ), + ); + await f.database + .into(f.database.davCollections) + .insert( + mixed.copyWith(id: 'other-collection', accountId: 'other-server'), + ); + }); + final accounts = AccountsRepository(database: f.database); + final settings = DavSettingsRepository(database: f.database); + final container = _container( + selectedAccountId: 'account', + authRepository: _FakeAuthRepository(), + accounts: (await tester.runAsync(accounts.listVisibleAccounts))!, + accountsRepository: accounts, + databaseOverride: f.database, + davCollectionStream: settings.watchCollections(), + davSettingsRepository: settings, + collectionService: f.collections, + ); + addTearDown(() async { + container.dispose(); + await tester.runAsync(f.close); + }); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1300), + ); + await settle(tester); + Future row(String id) async => (await tester.runAsync( + () => (f.database.select( + f.database.davCollections, + )..where((r) => r.id.equals(id))).getSingle(), + ))!; + Future toggle(String id, String domain) async { + final target = find.byKey(ValueKey('dav-$domain-toggle-$id')); + await tester.ensureVisible(target); + await tester.tap( + find.descendant(of: target, matching: find.byType(YaruSwitch)), + ); + await settle(tester); + } - expect(find.text('Add Google account'), findsOneWidget); - expect(find.text('Add Microsoft account'), findsOneWidget); - }); + await tester.tap(find.text('Appointments')); + await settle(tester); + expect((await row('events-only')).eventsSelected, isFalse); + expect((await row('tasks-only')).tasksSelected, isTrue); + final singleSettings = find.byKey( + const ValueKey('dav-collection-settings-events-only'), + ); + await tester.ensureVisible(singleSettings); + await tester.tap(singleSettings); + await settle(tester); + expect( + find.byKey(const ValueKey('nextcloud-collection-dialog')), + findsOneWidget, + ); + expect((await row('events-only')).eventsSelected, isFalse); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + final mixed = find.byKey( + const ValueKey('dav-collection-expander-collection'), + ); + await tester.ensureVisible(mixed); + await tester.tap(find.text('Work')); + await tester.pumpAndSettle(); + expect((await row('collection')).eventsSelected, isTrue); + expect((await row('collection')).tasksSelected, isTrue); + await toggle('collection', 'events'); + expect((await row('collection')).eventsSelected, isFalse); + expect((await row('collection')).tasksSelected, isTrue); + await toggle('collection', 'tasks'); + expect((await row('collection')).tasksSelected, isFalse); + // The button is its own activation target, even inside an expander. + final gear = find.byKey( + const ValueKey('dav-collection-settings-collection'), + ); + await tester.ensureVisible(gear); + await tester.tap(gear); + await settle(tester); + expect( + find.byKey(const ValueKey('nextcloud-collection-dialog')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('nextcloud-displayname')), + ) + .enabled, + isTrue, + ); + expect( + (await tester.runAsync(() => settings.collectionById('collection')))! + .capabilities + .canUpdateEvent, + isFalse, + ); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + expect(tester.widget(mixed).isExpanded, isTrue); + expect((await row('collection')).eventsSelected, isFalse); + expect((await row('collection')).tasksSelected, isFalse); + expect((await row('other-collection')).eventsSelected, isTrue); + expect((await row('other-collection')).tasksSelected, isTrue); + expect(f.requests.where((r) => r.method != 'PROPFIND'), isEmpty); + expect(tester.takeException(), isNull); + }, + ); testWidgets( 'Settings presents DAV calendars and task lists as named controls', (tester) async { + final semantics = tester.ensureSemantics(); final container = _container( selectedAccountId: _nextcloudAccount.id, authRepository: _FakeAuthRepository(), @@ -1399,7 +2891,7 @@ void main() { expect(find.text('Calendars and task lists'), findsOneWidget); expect(find.text('Collections'), findsNothing); - expect(find.text('Refresh calendars and task lists'), findsOneWidget); + expect(find.text('Refresh now'), findsOneWidget); final calendar = find.byKey( const ValueKey('dav-collection-personal-calendar'), @@ -1421,19 +2913,35 @@ void main() { ); expect(find.text('Personal'), findsOneWidget); expect(find.text('NCC Task List'), findsOneWidget); - expect( + await tester.ensureVisible( find.byKey(const ValueKey('dav-collection-settings-personal-calendar')), - findsOneWidget, ); + await tester.pumpAndSettle(); expect( - find.byKey(const ValueKey('dav-collection-settings-task-list')), + tester + .getSemantics( + find.descendant( + of: find.byKey( + const ValueKey('dav-collection-settings-personal-calendar'), + ), + matching: find.byType(IconButton), + ), + ) + .tooltip, + contains('Personal'), + ); + expect( + find.byKey(const ValueKey('dav-collection-settings-personal-calendar')), findsOneWidget, ); expect( - find.text('Task list · Read-only · Sync issue: CalDavUnavailable'), + find.byKey(const ValueKey('dav-collection-settings-task-list')), findsOneWidget, ); + expect(find.textContaining('Task list · Read-only'), findsOneWidget); + expect(find.textContaining('CalDavUnavailable'), findsNothing); expect(find.textContaining('Last synchronized:'), findsNothing); + semantics.dispose(); }, ); @@ -1448,8 +2956,8 @@ void main() { addTearDown(container.dispose); await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); - await tester.ensureVisible(find.text('Refresh calendars and task lists')); - await tester.tap(find.text('Refresh calendars and task lists')); + await tester.ensureVisible(find.text('Refresh now')); + await tester.tap(find.text('Refresh now')); await tester.pumpAndSettle(); expect(calls, [_nextcloudAccount.id]); @@ -1469,8 +2977,11 @@ void main() { await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); - expect(find.text('Refresh calendars and task lists'), findsNothing); - expect(find.text(accountReconnectRequiredActionLabel), findsOneWidget); + expect(find.text('Refresh now'), findsNothing); + expect( + find.text(accountReconnectRequiredActionLabel).hitTestable(), + findsOneWidget, + ); expect(calls, isEmpty); }); @@ -1498,8 +3009,8 @@ void main() { await _pumpSettings(tester, container, logicalSize: const Size(1000, 900)); repository.canonicalAccounts = const [_reconnectRequiredNextcloudAccount]; - await tester.ensureVisible(find.text('Refresh calendars and task lists')); - await tester.tap(find.text('Refresh calendars and task lists')); + await tester.ensureVisible(find.text('Refresh now')); + await tester.tap(find.text('Refresh now')); await tester.pumpAndSettle(); expect(calls, isEmpty); @@ -1558,7 +3069,12 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container, logicalSize: const Size(1000, 700)); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1000, 700), + openSelectedAccount: false, + ); expect(find.text('Add Google account'), findsOneWidget); expect(find.text('Theme'), findsNothing); @@ -2046,7 +3562,7 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + await _pumpSettings(tester, container, openSelectedAccount: false); expect(find.text('New task list'), findsNothing); expect(find.text('Calendar import'), findsOneWidget); @@ -2059,7 +3575,14 @@ void main() { ); expect(find.byKey(const ValueKey('import-ics-file')), findsOneWidget); expect(find.text('Import .ics file'), findsOneWidget); - expect(find.text('Remove account…'), findsNWidgets(2)); + expect( + find.byKey(const ValueKey('account-entry-google:g')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('account-entry-microsoft:m')), + findsOneWidget, + ); }); testWidgets('Settings lists accounts without account switching controls', ( @@ -2073,11 +3596,17 @@ void main() { ); addTearDown(container.dispose); - await _pumpSettings(tester, container); + await _pumpSettings(tester, container, openSelectedAccount: false); - expect(find.text('Google'), findsOneWidget); + expect( + find.byKey(const ValueKey('account-entry-google:g')), + findsOneWidget, + ); expect(find.text('Google User · google@example.com'), findsOneWidget); - expect(find.text('Microsoft'), findsOneWidget); + expect( + find.byKey(const ValueKey('account-entry-microsoft:m')), + findsOneWidget, + ); expect(find.text('Microsoft User · microsoft@example.com'), findsOneWidget); expect(find.text('Google Tasks'), findsNothing); expect(find.text('Microsoft To Do'), findsNothing); @@ -2202,6 +3731,17 @@ Finder _settingsMenuItemWithLabel(String label) { } Future _openAccountRemovalDialog(WidgetTester tester) async { + if (find.text('Remove account…').evaluate().isEmpty) { + final scope = ProviderScope.containerOf( + tester.element(find.byType(SettingsScreen)), + listen: false, + ); + final id = scope.read(selectedAccountIdProvider); + final entry = find.byKey(ValueKey('account-entry-$id')); + await tester.ensureVisible(entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + } final removeAction = find.text('Remove account…').first; await tester.ensureVisible(removeAction); await tester.pumpAndSettle(); @@ -2235,9 +3775,46 @@ ProviderContainer _container({ SignedInSyncRunner? signedInSyncRunner, Harness? productionHarness, ContactsFixture? contactsFixture, + Stream>? registrationSummaryStream, + Stream>? calendarSourceStream, + CalendarRepository? calendarRepository, + AppDatabase? databaseOverride, + Stream>? davCollectionStream, + DavSettingsRepository? davSettingsRepository, + NextcloudCollectionService? collectionService, + PendingOpResolutionService Function(String)? operationResolution, + Stream>? managedAccountStream, }) { + final database = + databaseOverride ?? + contactsFixture?.database ?? + productionHarness?.db ?? + AppDatabase.memoryForTests(); + if (databaseOverride == null && + contactsFixture == null && + productionHarness == null) { + addTearDown(database.close); + } return ProviderContainer( overrides: [ + databaseProvider.overrideWithValue(database), + if (davSettingsRepository != null) + davSettingsRepositoryProvider.overrideWithValue(davSettingsRepository), + if (operationResolution != null) + pendingOpResolutionServiceForAccountProvider.overrideWith( + (ref, id) => operationResolution(id), + ), + if (calendarRepository != null) + calendarRepositoryProvider.overrideWithValue(calendarRepository), + if (collectionService != null) + nextcloudCollectionServiceProvider.overrideWith((ref, id) { + expect(id, collectionService.accountId); + return collectionService; + }), + if (registrationSummaryStream != null) + registrationSummariesProvider.overrideWith( + (ref) => registrationSummaryStream, + ), if (contactsFixture != null) ...[ databaseProvider.overrideWithValue(contactsFixture.database), busyMaxContactsControllerProvider.overrideWithValue( @@ -2273,13 +3850,13 @@ ProviderContainer _container({ signedInSyncRunnerProvider.overrideWithValue(signedInSyncRunner), accountsStreamProvider.overrideWith((ref) => Stream.value(accounts)), accountManagementStreamProvider.overrideWith( - (ref) => Stream.value(accounts), + (ref) => managedAccountStream ?? Stream.value(accounts), ), davCollectionsStreamProvider.overrideWith( - (ref) => Stream.value(davCollections), + (ref) => davCollectionStream ?? Stream.value(davCollections), ), calendarSourcesStreamProvider.overrideWith( - (ref) => Stream.value(calendarSources), + (ref) => calendarSourceStream ?? Stream.value(calendarSources), ), if (googleSharingClient != null) googleCalendarApiClientForAccountProvider.overrideWith( @@ -2328,7 +3905,9 @@ Future _pumpSettings( Size? logicalSize, TextScaler? textScaler, Locale locale = const Locale('en'), + ThemeData? theme, SettingsPage initialPage = SettingsPage.accounts, + bool openSelectedAccount = true, }) async { if (logicalSize != null) { tester.view.devicePixelRatio = 1; @@ -2347,10 +3926,44 @@ Future _pumpSettings( await tester.pumpWidget( UncontrolledProviderScope( container: container, - child: localizedTestApp(locale: locale, child: settings), + child: localizedTestApp(locale: locale, theme: theme, child: settings), ), ); await tester.pumpAndSettle(); + if (initialPage == SettingsPage.accounts && openSelectedAccount) { + final id = container.read(selectedAccountIdProvider); + final entry = find.byKey(ValueKey('account-entry-$id')); + if (entry.evaluate().isNotEmpty) { + await tester.ensureVisible(entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + } + } +} + +Future _expandAccountAdvanced(WidgetTester tester, String id) async { + final advanced = find.byKey(ValueKey('account-advanced-$id')); + await tester.ensureVisible(advanced); + final fade = tester.widget( + find + .descendant(of: advanced, matching: find.byType(AnimatedCrossFade)) + .first, + ); + if (fade.crossFadeState == CrossFadeState.showSecond) { + await tester.tap( + find.descendant(of: advanced, matching: find.text('Advanced')).first, + ); + await tester.pumpAndSettle(); + } +} + +Future _expandCalendar(WidgetTester tester, String id) async { + final expander = find.byKey(ValueKey('settings-calendar-expander-$id')); + await tester.ensureVisible(expander); + await tester.tap( + find.descendant(of: expander, matching: find.byType(YaruIconButton)).last, + ); + await tester.pumpAndSettle(); } Future _disposeDiagnosticsWidget(WidgetTester tester) async { @@ -2468,6 +4081,441 @@ class _AccountRemovalCall { int get hashCode => Object.hash(accountId, revokeAuthorization); } +class _DelayedLookupAccountsRepository extends _FakeAccountsRepository { + _DelayedLookupAccountsRepository(super.accounts); + final lookup = Completer(); + @override + Future accountById(String accountId) => lookup.future; +} + +/// Holds actual repository reads at the UI preparation boundary. The query +/// completes against SQLite before yielding, so other inventories remain usable. +class _ContactLookupGate extends QueryInterceptor { + String? statementContaining; + bool fail = false; + Completer entered = Completer(); + Completer release = Completer(); + + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + final rows = await super.runSelect(executor, statement, args); + final target = statementContaining; + if (target != null && statement.contains(target)) { + statementContaining = null; + entered.complete(); + await release.future; + if (fail) throw StateError('synthetic inventory read failure'); + } + return rows; + } +} + +void registerContactsOverviewRegressions() { + for (final pause in [ + 'linked linkage', + 'linked account', + 'independent linkage', + 'independent credential', + ]) { + for (final destination in ['account', 'category']) { + testWidgets( + 'Contacts Reconnect preparation belongs to its view: $pause → $destination', + (tester) async { + final linked = pause.startsWith('linked'); + final gate = _ContactLookupGate(); + final secretEntered = Completer(); + final secretRelease = Completer(); + var holdSecret = false; + late ContactsFixture f; + f = ContactsFixture( + AppDatabase(NativeDatabase.memory().interceptWith(gate)), + readDavSecret: (id) async { + final encoded = f.davSecrets[id]; + if (holdSecret) { + holdSecret = false; + secretEntered.complete(); + await secretRelease.future; + } + return encoded; + }, + ); + final contact = ContactAccount( + id: linked ? 'contacts:nextcloud:n' : 'independent-qa', + provider: ContactProviderKind.carddav, + displayName: 'Synthetic contacts', + subject: 'qa-user', + enabled: true, + errorCode: 'contacts:authentication:synthetic-expired', + grantedScopes: const {'carddav:read'}, + ); + await tester.runAsync(() async { + await f.accounts.upsertSignedInAccount( + id: _nextcloudAccount.id, + provider: BusyProvider.nextcloud, + providerAccountId: _nextcloudAccount.providerAccountId, + authority: _nextcloudAccount.authority, + grantedScopes: '', + ); + if (linked) { + await f.store.saveLinkedAccount( + account: contact, + busyMaxAccountId: _nextcloudAccount.id, + reuseAuthorization: true, + ); + } else { + await f.store.write((tx) => tx.putAccount(contact)); + f.davSecrets[contact.id] = jsonEncode({ + 'version': 1, + 'server': 'https://contacts.example.test', + 'username': contact.subject, + 'password': 'synthetic-only', + 'readOnly': true, + }); + } + }); + final container = _container( + selectedAccountId: _nextcloudAccount.id, + authRepository: _FakeAuthRepository(), + accounts: const [_nextcloudAccount, _googleAccount], + contactsFixture: f, + accountsRepository: f.accounts, + ); + addTearDown(() async { + if (!gate.release.isCompleted) gate.release.complete(); + if (!secretRelease.isCompleted) secretRelease.complete(); + await _disposeDiagnosticsWidget(tester); + container.dispose(); + await complete(tester, f.close); + }); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1300), + openSelectedAccount: linked, + ); + await complete( + tester, + () => container.read(independentContactAccountIdsProvider.future), + ); + await tester.pumpAndSettle(); + if (!linked) { + final entry = find.byKey( + ValueKey('contact-account-entry-${contact.id}'), + ); + await tester.ensureVisible(entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + } + if (pause.endsWith('linkage')) { + gate.statementContaining = 'SELECT busymax_account_id FROM'; + } else if (linked) { + gate.statementContaining = 'FROM "accounts" WHERE'; + } else { + holdSecret = true; + } + final reconnect = find.byKey( + ValueKey( + linked + ? 'reconnect-contacts-${_nextcloudAccount.id}' + : 'contact-account-recovery-${contact.id}', + ), + ); + await tester.ensureVisible(reconnect); + await tester.tap(reconnect); + await complete( + tester, + () => pause.endsWith('credential') + ? secretEntered.future + : gate.entered.future, + ); + if (destination == 'category') { + await tester.tap( + find.byKey(const ValueKey('settings-navigation-schedule')), + ); + } else { + await tester.tap( + find.byKey(const ValueKey('settings-header-back-button')), + ); + await tester.pumpAndSettle(); + final other = find.byKey(const ValueKey('account-entry-google:g')); + await tester.ensureVisible(other); + await tester.tap(other); + } + await tester.pumpAndSettle(); + gate.release.complete(); + secretRelease.complete(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('nextcloud-server-field')), findsNothing); + expect(find.byType(BusyMaxPromptDialog), findsNothing); + expect(f.requests, isEmpty); + expect( + (await complete( + tester, + () => f.store.read((tx) => tx.account(contact.id)), + ))!.toJson(), + contact.toJson(), + ); + expect(tester.takeException(), isNull); + }, + ); + } + } + + for (final inventory in ['accounts', 'independent IDs']) { + for (final cached in [false, true]) { + testWidgets( + 'Independent contact inventory failure and Retry retain stored entries: $inventory cached=$cached', + (tester) async { + final gate = _ContactLookupGate(); + final f = ContactsFixture( + AppDatabase(NativeDatabase.memory().interceptWith(gate)), + ); + const contact = ContactAccount( + id: 'independent-qa', + provider: ContactProviderKind.carddav, + displayName: 'Existing synthetic contact account', + subject: 'qa-user', + enabled: false, + grantedScopes: {'carddav:read'}, + ); + await tester.runAsync( + () => f.store.write((tx) => tx.putAccount(contact)), + ); + void blockInventory() { + gate.statementContaining = inventory == 'accounts' + ? 'SELECT data FROM bm_contact_accounts ORDER BY id' + : 'SELECT a.id FROM bm_contact_accounts a'; + gate.fail = true; + } + + if (!cached) blockInventory(); + final container = _container( + selectedAccountId: '', + authRepository: _FakeAuthRepository(), + accounts: const [], + contactsFixture: f, + ); + addTearDown(() async { + if (!gate.release.isCompleted) gate.release.complete(); + await _disposeDiagnosticsWidget(tester); + container.dispose(); + await complete(tester, f.close); + }); + if (!cached && inventory == 'independent IDs') { + // Settle its dependency before holding the first independent-ID + // read; a superseded earlier read must not satisfy this test. + await complete( + tester, + () => container.read(busyMaxContactAccountsProvider.future), + ); + } + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1300), + openSelectedAccount: false, + ); + if (cached) { + await complete( + tester, + () => container.read(independentContactAccountIdsProvider.future), + ); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('contact-account-entry-independent-qa'), + ), + findsOneWidget, + ); + blockInventory(); + if (inventory == 'accounts') { + container.invalidate(busyMaxContactAccountsProvider); + } else { + container.invalidate(independentContactAccountIdsProvider); + } + } + await complete(tester, () => gate.entered.future); + await tester.pump(); + expect( + find.byKey(const ValueKey('contacts-inventory-loading')), + findsOneWidget, + ); + gate.release.complete(); + await complete(tester, () async { + try { + if (inventory == 'accounts') { + await container.read(busyMaxContactAccountsProvider.future); + } else { + await container.read( + independentContactAccountIdsProvider.future, + ); + } + } on StateError { + /* Expected real SQLite-read fixture failure. */ + } + }); + await tester.pumpAndSettle(); + expect(find.text('Could not load contact accounts'), findsOneWidget); + expect( + find.byKey(const ValueKey('contacts-inventory-loading')), + findsNothing, + ); + expect(find.textContaining('synthetic inventory'), findsNothing); + expect( + find.byKey(const ValueKey('add-carddav-contacts')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('contact-account-entry-independent-qa')), + cached ? findsOneWidget : findsNothing, + ); + await tester.tap( + find.byKey(const ValueKey('contacts-inventory-retry')), + ); + await complete( + tester, + () => container.read(independentContactAccountIdsProvider.future), + ); + await tester.pumpAndSettle(); + expect(find.text('Could not load contact accounts'), findsNothing); + expect( + find.byKey(const ValueKey('contact-account-entry-independent-qa')), + findsOneWidget, + ); + expect( + (await complete( + tester, + () => f.store.read((tx) => tx.accounts()), + )).single.toJson(), + contact.toJson(), + ); + expect(f.requests, isEmpty); + expect(tester.takeException(), isNull); + }, + ); + } + } + for (final brightness in Brightness.values) { + testWidgets( + 'Independent contact removal stays readable and confirmed: $brightness', + (tester) async { + final f = ContactsFixture(AppDatabase.memoryForTests()); + const contact = ContactAccount( + id: 'independent-qa', + provider: ContactProviderKind.carddav, + displayName: 'Synthetic contacts', + subject: 'qa-user', + enabled: false, + grantedScopes: {'carddav:read'}, + ); + await tester.runAsync( + () => f.store.write((tx) => tx.putAccount(contact)), + ); + final container = _container( + selectedAccountId: '', + authRepository: _FakeAuthRepository(), + accounts: const [], + contactsFixture: f, + ); + addTearDown(() async { + await _disposeDiagnosticsWidget(tester); + container.dispose(); + await complete(tester, f.close); + }); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1100), + openSelectedAccount: false, + theme: BusyMaxYaruTheme.build( + brightness: brightness, + accentColor: YaruColors.orange, + ), + ); + await complete( + tester, + () => container.read(independentContactAccountIdsProvider.future), + ); + await tester.pumpAndSettle(); + final entry = find.byKey( + const ValueKey('contact-account-entry-independent-qa'), + ); + await tester.ensureVisible(entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + final row = find.byKey( + const ValueKey('remove-contacts-only-independent-qa'), + ); + await tester.ensureVisible(row); + final label = find.descendant(of: row, matching: find.text('Remove')); + final pointer = await tester.createGesture( + kind: ui.PointerDeviceKind.mouse, + ); + addTearDown(pointer.removePointer); + for (final interaction in ['normal', 'hovered', 'focused']) { + if (interaction == 'hovered') { + await pointer.addPointer(location: Offset.zero); + await pointer.moveTo(tester.getCenter(row)); + } else if (interaction == 'focused') { + await pointer.moveTo(Offset.zero); + Focus.of(tester.element(label)).requestFocus(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + } + await tester.pumpAndSettle(); + final paragraph = tester.renderObject(label); + final foreground = paragraph.text.style!.color!; + final context = tester.element(label); + expect( + foreground, + DefaultTextStyle.of(context).style.color, + reason: 'Use the ordinary preference-row foreground', + ); + final surface = busyMaxGroupedSurfaceColor(context); + final background = Color.alphaBlend(switch (interaction) { + 'hovered' => busyMaxRowHoverColor(context), + 'focused' => Theme.of(context).focusColor, + _ => Colors.transparent, + }, surface); + final visibleText = Color.alphaBlend(foreground, background); + final luminances = [ + visibleText.computeLuminance(), + background.computeLuminance(), + ]..sort(); + expect( + (luminances.last + 0.05) / (luminances.first + 0.05), + greaterThanOrEqualTo(4.5), + reason: + '$interaction: foreground=$foreground background=$background', + ); + } + expect(Focus.of(tester.element(label)).hasFocus, isTrue); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + // The operation's busy indicator remains active behind its modal. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(BusyMaxConfirmDialog), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect( + (await complete( + tester, + () => f.store.read((tx) => tx.account(contact.id)), + ))!.toJson(), + contact.toJson(), + ); + expect(tester.takeException(), isNull); + }, + ); + } +} + class _FakeAccountsRepository implements AccountsRepository { const _FakeAccountsRepository(this.accounts); @@ -2684,9 +4732,11 @@ class _ObservedAuthRepository extends AuthRepository { authorizationPersistence: h.persistence, ); Future? finished; + AuthorizationRequest? lastRequest; Completer started = Completer(); @override Future signIn({AuthorizationRequest? request}) { + lastRequest = request; if (!started.isCompleted) started.complete(); final operation = super.signIn(request: request); finished = operation.then( @@ -2695,124 +4745,150 @@ class _ObservedAuthRepository extends AuthRepository { ); return operation; } + + @override + Future signInWithMicrosoft({ + AuthorizationRequest? request, + }) { + lastRequest = request; + if (!started.isCompleted) started.complete(); + final operation = super.signInWithMicrosoft(request: request); + finished = operation.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return operation; + } } void registerSettingsAuthorizationCancellationRegressions() { for (final shared in [false, true]) { - for (final dispose in [false, true]) { + for (final exit in ['Cancel control', 'disposal', 'Back navigation']) { for (final pause in ['binding', 'browser', 'exchange', 'identity']) { - testWidgets( - 'real Settings ${dispose ? 'disposal' : 'Cancel control'} owns OAuth $pause shared=$shared', - (tester) async { - // The production database/transport futures run in the real I/O zone. - // Rendering pumps cannot be used as a substitute for their barriers. - await tester.runAsync(() async { - final h = Harness(buildConfig: syntheticDesktopConfig()); - addTearDown(() async { - h.staging.dispose(); - await h.db.close(); + testWidgets('real Settings $exit owns OAuth $pause shared=$shared', ( + tester, + ) async { + // The production database/transport futures run in the real I/O zone. + // Rendering pumps cannot be used as a substitute for their barriers. + await tester.runAsync(() async { + final h = Harness(buildConfig: syntheticDesktopConfig()); + addTearDown(() async { + h.staging.dispose(); + await h.db.close(); + }); + await h.seed(); + if (!shared) await h.makeGoogleUserOwned(); + final before = (await h.secrets.readCredential('opaque'))!.toJson(); + final observing = _ObservedAuthRepository(h); + final container = _container( + selectedAccountId: 'opaque', + authRepository: observing, + accounts: await h.accounts.listVisibleAccounts(), + accountsRepository: h.accounts, + buildConfig: _configuredBuildConfig, + productionHarness: h, + signedInSyncRunner: (_, _) async {}, + ); + addTearDown(container.dispose); + await _pumpSettings( + tester, + container, + logicalSize: const Size(1200, 1200), + ); + final summaries = await container.read( + registrationSummariesProvider.future, + ); + await tester.pump(); + expect(summaries['opaque']!.showRetirementNotice, shared); + final entered = Completer(), release = Completer(); + Future? holding; + if (pause == 'binding') { + holding = h.persistence.run('opaque', () async { + entered.complete(); + await release.future; }); - await h.seed(); - if (!shared) await h.makeGoogleUserOwned(); - final before = (await h.secrets.readCredential('opaque'))! - .toJson(); - final observing = _ObservedAuthRepository(h); - final container = _container( - selectedAccountId: 'opaque', - authRepository: observing, - accounts: await h.accounts.listVisibleAccounts(), - accountsRepository: h.accounts, - buildConfig: _configuredBuildConfig, - productionHarness: h, - signedInSyncRunner: (_, _) async {}, - ); - addTearDown(container.dispose); - await _pumpSettings( - tester, - container, - logicalSize: const Size(1200, 1200), - ); - final summaries = await container.read( - registrationSummariesProvider.future, - ); - await tester.pump(); - expect(summaries['opaque']!.showRetirementNotice, shared); - final entered = Completer(), release = Completer(); - Future? holding; - if (pause == 'binding') { - holding = h.persistence.run('opaque', () async { - entered.complete(); + await entered.future; + } else if (pause == 'browser') { + h.flow.barrier = release; + } else { + h.beforeRequest = (request) async { + if ((pause == 'exchange' && request.method == 'POST') || + (pause == 'identity' && request.method == 'GET')) { + if (!entered.isCompleted) entered.complete(); await release.future; - }); - await entered.future; - } else if (pause == 'browser') { - h.flow.barrier = release; - } else { - h.beforeRequest = (request) async { - if ((pause == 'exchange' && request.method == 'POST') || - (pause == 'identity' && request.method == 'GET')) { - if (!entered.isCompleted) entered.complete(); - await release.future; - } - return null; - }; - } - await _startStoredGoogleReconnect( - tester, - observing, - replacement: shared, - ); - if (pause == 'browser') { - await h.flow.started.future; - } else if (pause != 'binding') { - await entered.future; - } - await tester.pump(); - if (dispose) { - await tester.pumpWidget(const SizedBox()); - } else { - final cancel = find.text('Cancel connection'); - await tester.ensureVisible(cancel); - await tester.tap(cancel); - await tester.pump(); - } - release.complete(); - await holding; - await observing.finished; + } + return null; + }; + } + await _startStoredGoogleReconnect( + tester, + observing, + replacement: shared, + ); + if (pause == 'browser') { + await h.flow.started.future; + } else if (pause != 'binding') { + await entered.future; + } + await tester.pump(); + if (exit == 'disposal') { + await tester.pumpWidget(const SizedBox()); + } else if (exit == 'Back navigation') { + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); await tester.pumpAndSettle(); expect( - (await h.secrets.readCredential('opaque'))!.toJson(), - before, + find.byKey(const ValueKey('account-entry-opaque')), + findsOneWidget, ); - expect((await h.summary('opaque')).showRetirementNotice, shared); - if (pause == 'binding') expect(h.flow.clients, isEmpty); - if (dispose) { - await _pumpSettings( - tester, - container, - logicalSize: const Size(1200, 1200), - ); - } - h.beforeRequest = null; - h.flow.barrier = null; - await _startStoredGoogleReconnect( + } else { + final cancel = find.text('Cancel connection'); + await tester.ensureVisible(cancel); + await tester.tap(cancel); + await tester.pump(); + } + release.complete(); + await holding; + await observing.finished; + await tester.pumpAndSettle(); + expect( + (await h.secrets.readCredential('opaque'))!.toJson(), + before, + ); + expect((await h.summary('opaque')).showRetirementNotice, shared); + if (pause == 'binding') expect(h.flow.clients, isEmpty); + if (exit == 'disposal') { + await _pumpSettings( tester, - observing, - replacement: shared, + container, + logicalSize: const Size(1200, 1200), ); - await observing.finished; + } else if (exit == 'Back navigation') { + final entry = find.byKey(const ValueKey('account-entry-opaque')); + await tester.ensureVisible(entry); + await tester.tap(entry); await tester.pumpAndSettle(); - expect( - (await h.secrets.readCredential( - 'opaque', - ) as GoogleDesktopCredential).tokenSet.accessToken, - 'candidate-access', - ); - expect((await h.summary('opaque')).showRetirementNotice, false); - await tester.pumpWidget(const SizedBox()); - }); - }, - ); + } + h.beforeRequest = null; + h.flow.barrier = null; + await _startStoredGoogleReconnect( + tester, + observing, + replacement: shared, + ); + await observing.finished; + await tester.pumpAndSettle(); + expect( + (await h.secrets.readCredential( + 'opaque', + ) as GoogleDesktopCredential).tokenSet.accessToken, + 'candidate-access', + ); + expect((await h.summary('opaque')).showRetirementNotice, false); + await tester.pumpWidget(const SizedBox()); + }); + }); } } } @@ -2824,9 +4900,10 @@ Future _startStoredGoogleReconnect( required bool replacement, }) async { observing.started = Completer(); - final action = find - .text(replacement ? 'Reconnect Google account' : 'Connect') - .last; + await _expandAccountAdvanced(tester, 'opaque'); + final action = replacement + ? find.text('Migrate now') + : find.byKey(const ValueKey('reconnect-account-opaque')); await tester.ensureVisible(action); await tester.tap(action); if (replacement) { diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index c0fe6947..f3df56ec 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -65,6 +65,144 @@ void main() { await database.close(); }); + test('a further reminder change between checked GET and PATCH is protected by the checked ETag', () async { + Map reminders(int minutes) => { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': minutes}, + ], + }; + Map resource(int minutes, String etag) => { + 'id': 'reminder-race', + 'summary': 'Reminder', + 'organizer': {'self': true}, + 'start': {'dateTime': '2026-06-08T09:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-08T10:00:00Z', 'timeZone': 'UTC'}, + 'updated': '2026-06-08T00:00:00Z', + 'etag': etag, + 'reminders': reminders(minutes), + }; + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('cal-1', resource(10, '"baseline"')), + ); + final local = await database.select(database.calendarEvents).getSingle(); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(local.id))!, + ).copyWith(reminders: reminders(30)), + ); + var remote = resource(10, '"checked"'); + var patches = 0; + final api = GoogleCalendarApiClient( + baseUri: Uri.parse('https://www.googleapis.com'), + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode(remote), 200); + } + expect(request.method, 'PATCH'); + expect(request.headers['if-match'], '"checked"'); + expect(jsonDecode(request.body)['reminders'], reminders(30)); + remote = resource(15, '"changed-after-get"'); + patches++; + return http.Response( + '{"error":{"status":"PRECONDITION_FAILED","message":"ETag changed"}}', + 412, + ); + }), + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 0, + ); + expect(patches, 1); + expect(remote['reminders'], reminders(15)); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.lastErrorCode, 'conflict'); + expect(jsonDecode(pending.requestJson)['remindersJson'], reminders(30)); + }); + + for (final conflictingReminder in [false, true]) { + test( + 'Google reminder edits protect the baseline (remote reminder conflict: $conflictingReminder)', + () async { + Map reminders(int minutes) => { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': minutes}, + ], + }; + final repository = CalendarRepository(database: database); + final baseline = client._event( + 'reminder-event', + title: 'Base', + organizerJson: {'self': true}, + remindersJson: reminders(10), + etagOrChangeKey: '"baseline"', + ); + await repository.upsertEvent(accountId: 'account', event: baseline); + final detail = (await repository.loadEventDetail( + CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'reminder-event', + ), + ))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail) + .copyWith(reminders: reminders(30)), + ); + client.remoteEvent = client._event( + 'reminder-event', + title: 'Base', + organizerJson: {'self': true}, + description: conflictingReminder ? null : 'Remote description', + remindersJson: reminders(conflictingReminder ? 15 : 10), + etagOrChangeKey: '"checked"', + updatedAtServer: conflictingReminder + ? baseline.updatedAtServer! + : '2026-06-08T00:01:00.000Z', + ); + client.persistEventUpdates = true; + final applied = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + if (conflictingReminder) { + expect(applied, 0); + expect(client.updatedMutations, isEmpty); + expect( + (await database.select(database.pendingOps).getSingle()) + .lastErrorCode, + 'conflict', + ); + expect(client.remoteEvent!.remindersJson, reminders(15)); + expect( + jsonDecode( + (await database.select(database.calendarEvents).getSingle()) + .remindersJson!, + ), + reminders(30), + ); + } else { + expect(applied, 1); + expect(client.ifMatches, ['"checked"']); + expect(client.updatedMutations.single.description, isNull); + expect(client.remoteEvent!.description, 'Remote description'); + } + }, + ); + } + test('guarded timing edit replays exact endpoints and guest policy without moving ownership', () async { final repository = CalendarRepository(database: database); final id = await _insertEvent( @@ -3078,15 +3216,14 @@ END:VEVENT test('zoned guarded series edit replays the same civil delta', () async { final repository = CalendarRepository(database: database); - final id = await _insertGoogleOccurrence(repository, day: 8); - await database - .update(database.calendarEvents) - .write( - const CalendarEventsCompanion( - startTimeZone: Value('America/Vancouver'), - endTimeZone: Value('America/Vancouver'), - ), - ); + // Ingest the provider's zone together with its timestamps. Rewriting only + // the zone columns would turn the master's zone-less 09:00 into 09:00 + // Vancouver while leaving this 09:00Z occurrence at 02:00 Vancouver. + final id = await _insertGoogleOccurrence( + repository, + day: 8, + timeZone: 'America/Vancouver', + ); final detail = (await repository.loadEventDetail(id))!; await repository.updateLocalEvent( EventEditorDraft.fromEventDetail(detail).copyWith( @@ -3209,6 +3346,128 @@ END:VEVENT ); } + test('ordinary following edit preserves the earlier local weekday across UTC midnight', () async { + final repository = CalendarRepository(database: database); + const zone = 'America/Vancouver'; + final master = googleCalendarEventFromJson('cal-1', { + ..._googleSeriesMaster( + timeZone: zone, + start: '2026-06-01T23:00:00-07:00', + end: '2026-06-02T00:00:00-07:00', + ).rawJson, + 'recurrence': ['RRULE:FREQ=WEEKLY;COUNT=5'], + }); + await repository.upsertEvent(accountId: 'account', event: master); + final selected = await _insertGoogleOccurrence( + repository, + day: 8, + start: '2026-06-08T23:00:00-07:00', + end: '2026-06-09T00:00:00-07:00', + timeZone: zone, + ); + final detail = (await repository.loadEventDetail(selected))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + start: providerCivilDateTime(DateTime.utc(2026, 6, 9, 23)), + end: providerCivilDateTime(DateTime.utc(2026, 6, 10)), + recurringMutationScope: RecurringEventMutationScope.thisAndFuture, + ), + ); + client.remoteEvent = master; + client.eventInstances = [ + for (final day in [1, 8, 15, 22, 29]) + googleCalendarEventFromJson('cal-1', { + 'id': 'night-$day', + 'recurringEventId': 'series-master', + 'originalStartTime': { + 'dateTime': DateTime.utc(2026, 6, day + 1, 6).toIso8601String(), + }, + 'start': { + 'dateTime': DateTime.utc(2026, 6, day + 1, 6).toIso8601String(), + 'timeZone': zone, + }, + 'end': { + 'dateTime': DateTime.utc(2026, 6, day + 1, 7).toIso8601String(), + 'timeZone': zone, + }, + }), + ]; + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 1), + ).replayDueOps(), + 1, + ); + final trimmed = + googleEventMutationToJson(client.updatedMutations.single)['recurrence'] + as List; + expect(trimmed.single, contains('BYDAY=MO')); + expect(trimmed.single, contains('UNTIL=20260609T055959Z')); + expect( + googleEventMutationToJson( + client.createdMutations.single, + )['recurrence'].toString(), + contains('BYDAY=TU'), + ); + }); + + for (final following in [false, true]) { + test( + 'ordinary cross-day ${following ? 'following' : 'entire-series'} edit agrees with the weekly provider pattern', + () async { + final repository = CalendarRepository(database: database); + final ids = [ + for (final day in [1, 8, 15]) + await _insertGoogleOccurrence(repository, day: day), + ]; + final detail = (await repository.loadEventDetail(ids[1]))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith( + start: DateTime.utc(2026, 6, 9, 9), + end: DateTime.utc(2026, 6, 9, 10), + recurringMutationScope: following + ? RecurringEventMutationScope.thisAndFuture + : RecurringEventMutationScope.entireSeries, + ), + ); + final optimistic = (await repository.loadEventDetail(ids[2]))!; + expect( + providerDateTimeAsUtcInstant( + optimistic.startDateTime, + optimistic.startTimeZone, + ), + DateTime.utc(2026, 6, 16, 9), + ); + if (following) { + expect( + (await repository.loadEventDetail(ids[0]))!.startDateTime, + '2026-06-01T09:00:00.000Z', + ); + } + client.remoteEvent = _googleSeriesMaster(); + client.eventInstances = [ + for (final day in [1, 8, 15, 22, 29]) _googleSeriesInstance(day: day), + ]; + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 1, + ); + final mutation = following + ? client.createdMutations.single + : client.updatedMutations.single; + expect(mutation.recurrence.toString(), contains('BYDAY=TU')); + }, + ); + } + test('entire-series edit patches the recurring master', () async { final repository = CalendarRepository(database: database); final ids = []; @@ -3400,8 +3659,13 @@ END:VEVENT expect(await replay(8), 1); expect(client.remoteEvent!.startDateTime, '2026-06-01T10:00:00.000'); expect(client.remoteEvent!.endDateTime, '2026-06-01T11:00:00.000'); - for (final id in ids) { - final detail = (await repository.loadEventDetail(id))!; + final optimistic = + (await database.select(database.calendarEvents).get()) + .where((row) => row.providerRecurringEventId == 'series-master') + .toList(); + expect(optimistic, hasLength(ids.length)); + for (final row in optimistic) { + final detail = (await repository.loadEventDetail(row.id))!; expect(detail.syncStatus, 'pending'); expect( providerDateTimeAsCivilTime(detail.startDateTime, 'UTC')!.hour, @@ -3711,6 +3975,7 @@ END:VEVENT final splitSeriesId = operation.id.replaceAll('-', '').toLowerCase(); client ..remoteEvent = _googleSeriesMaster(location: 'Room 2') + ..persistEventUpdates = true ..eventInstances = [ _googleSeriesInstance(day: 1, location: 'Room 2'), _googleSeriesInstance(day: 8, location: 'Room 2'), @@ -6278,7 +6543,28 @@ Future _insertGoogleOccurrence( if (await repository.loadEventDetail(masterId) == null) { await repository.upsertEvent( accountId: 'account', - event: _googleSeriesMaster(timeZone: timeZone, location: location), + event: _googleSeriesMaster( + timeZone: timeZone, + location: location, + start: providerWallTimeIso8601String( + DateTime.utc( + providerDateTimeAsCivilTime(start, timeZone)!.year, + providerDateTimeAsCivilTime(start, timeZone)!.month, + 1, + providerDateTimeAsCivilTime(start, timeZone)!.hour, + providerDateTimeAsCivilTime(start, timeZone)!.minute, + ), + ), + end: providerWallTimeIso8601String( + DateTime.utc( + providerDateTimeAsCivilTime(end, timeZone)!.year, + providerDateTimeAsCivilTime(end, timeZone)!.month, + 1, + providerDateTimeAsCivilTime(end, timeZone)!.hour, + providerDateTimeAsCivilTime(end, timeZone)!.minute, + ), + ), + ), ); } await repository.upsertEvent( @@ -6343,7 +6629,16 @@ CalendarEventDto _googleSeriesMaster({ String updatedAtServer = '2026-05-30T00:00:00.000Z', String? location, }) { - const recurrence = ['RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;COUNT=5']; + final day = const [ + 'MO', + 'TU', + 'WE', + 'TH', + 'FR', + 'SA', + 'SU', + ][providerDateTimeAsCivilTime(start, timeZone)!.weekday - 1]; + final recurrence = ['RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=$day;COUNT=5']; return CalendarEventDto( provider: BusyProvider.google, providerCalendarId: 'cal-1', @@ -6473,6 +6768,7 @@ String _icalCalendar(String components) => class _FakeCalendarClient implements CloudCalendarClient, + SeriesExceptionClient, CompleteRecurringInstanceClient, PrivateCalendarImportClient, CalendarListManagementClient { @@ -6677,6 +6973,7 @@ class _FakeCalendarClient providerCalendarId: calendarId, startDateTime: mutation.startDateTime ?? current?.startDateTime, endDateTime: mutation.endDateTime ?? current?.endDateTime, + recurrenceJson: mutation.recurrence ?? current?.recurrenceJson, ); if (persistEventUpdates) { if (createdIdentity != null) { @@ -6891,6 +7188,45 @@ class _FakeCalendarClient .toList(growable: false); } + @override + Future> readSeriesExceptions({ + required String calendarId, + required CalendarEventDto master, + }) async => const []; + + @override + Future findSeriesInstance({ + required String calendarId, + required String recurringEventId, + required DateTime originalStart, + required bool allDay, + required String? timeZone, + }) async { + final start = providerWallTimeToInstant(originalStart, timeZone); + final master = remoteEventsById[recurringEventId] ?? remoteEvent; + final raw = { + 'id': 'resolved-${originalStart.toIso8601String()}', + 'recurringEventId': recurringEventId, + 'originalStartTime': {'dateTime': start.toIso8601String()}, + 'summary': master?.title ?? 'Base', + if (master?.location != null) 'location': master!.location, + 'start': {'dateTime': start.toIso8601String(), 'timeZone': timeZone}, + 'end': { + 'dateTime': start.add(const Duration(hours: 1)).toIso8601String(), + 'timeZone': timeZone, + }, + }; + return googleCalendarEventFromJson(calendarId, raw); + } + + @override + Future restoreSeriesException({ + required String calendarId, + required CalendarEventDto instance, + required CalendarEventDto exception, + required CalendarGuestUpdatePolicy guestUpdatePolicy, + }) async => throw UnimplementedError(); + @override Future> listAllEventInstances({ required String calendarId, diff --git a/test/features/sync/calendar_series_timing_integration_test.dart b/test/features/sync/calendar_series_timing_integration_test.dart new file mode 100644 index 00000000..374c65da --- /dev/null +++ b/test/features/sync/calendar_series_timing_integration_test.dart @@ -0,0 +1,1205 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/core/time/provider_date_time.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/features/sync/calendar_pending_ops_replayer.dart'; +import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; +import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; +import 'package:busymax/src/features/recurrence/domain/series_timing_change.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; +import 'package:busymax/src/google_calendar/google_calendar_errors.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_event_rescheduling.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import '../../support/persistence_test_directory.dart'; + +void main() { + test( + 'monthly date edit requires approval and adds its new dates before replay', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'fixture', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: '2026-01-01T00:00:00Z', + updatedAtUtc: '2026-01-01T00:00:00Z', + ), + ); + final server = _GoogleSeriesServer(monthly: true); + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 1, 1), + ); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Fixture', + timeZone: 'UTC', + accessRole: 'owner', + ), + ); + for (final resource in server.resources.values) { + await repository.upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('calendar', resource), + ); + } + await repository.saveSyncState( + accountId: 'account', + provider: BusyProvider.google, + syncKind: 'events', + calendarSourceId: 'account|google|calendar', + rangeStart: '2026-01-01T00:00:00Z', + rangeEnd: '2026-08-01T00:00:00Z', + cursorKind: 'google_sync_token', + cursorValue: 'baseline', + full: true, + ); + final original = (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.providerEventId == 'old-a'); + final detail = (await repository.loadEventDetail(original.id))!; + final draft = EventEditorDraft.fromEventDetail(detail).copyWith( + start: DateTime.utc(2026, 2, 1, 9), + end: DateTime.utc(2026, 2, 1, 10), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ); + final proposed = (await repository.prepareSeriesTimingChange(draft))!; + expect(proposed.requiresConfirmation, isTrue); + expect(proposed.rule.byMonthDay, [1]); + expect(proposed.rule.count, 4); + await expectLater( + repository.updateLocalEvent(draft), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + final approved = draft.copyWith(seriesTimingApproval: proposed.signature); + await expectLater( + repository.updateLocalEvent( + approved.copyWith( + start: DateTime.utc(2026, 3, 1, 9), + end: DateTime.utc(2026, 3, 1, 10), + ), + ), + throwsA(isA()), + ); + final item = + (await ScheduleRepository(database).listItems( + range: ScheduleRange( + start: DateTime.utc(2026, 1), + end: DateTime.utc(2026, 8), + ), + )) + .whereType() + .singleWhere((item) => item.id == original.id); + var confirmations = 0; + for (final confirm in [false, true]) { + expect( + await ScheduleReschedulingCoordinator( + repository: repository, + chooseScope: (_, _) async => + RecurringEventMutationScope.entireSeries, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + confirmSeriesTimingChange: (change, scope) async { + confirmations++; + expect(change.requiresConfirmation, isTrue); + expect(change.shiftExceptions, isTrue); + expect(change.originalRule.byMonthDay, [31]); + expect(change.rule.byMonthDay, [1]); + expect(change.previewRule(following: false).count, 4); + expect(await database.select(database.pendingOps).get(), isEmpty); + return confirm; + }, + requestSync: (_) async {}, + ).commit( + ScheduleRescheduleRequest( + item: item, + interval: ScheduleInterval(draft.start!, draft.end!), + ), + ), + confirm + ? ScheduleRescheduleResult.saved + : ScheduleRescheduleResult.cancelled, + ); + } + expect(confirmations, 2); + Future check() async { + final active = (await database.select(database.calendarEvents).get()) + .where( + (row) => + row.providerRecurringEventId != null && + !row.isDeleted && + !row.isCancelled, + ) + .toList(); + expect( + active.map( + (row) => providerDateTimeAsUtcInstant( + row.startDateTime, + row.startTimeZone, + ), + ), + unorderedEquals([ + DateTime.utc(2026, 2, 1, 9), + DateTime.utc(2026, 3, 1, 9), + DateTime.utc(2026, 4, 1, 9), + DateTime.utc(2026, 5, 1, 9), + ]), + ); + } + + await check(); + final pending = await database.select(database.pendingOps).getSingle(); + await PendingOpResolutionService( + database: database, + accountId: 'account', + syncTasks: () async {}, + syncCalendar: () async {}, + ).discard(pending.id); + expect(await database.select(database.pendingOps).get(), isEmpty); + final restored = (await database.select(database.calendarEvents).get()) + .where( + (row) => + row.providerRecurringEventId != null && + !row.isDeleted && + !row.isCancelled, + ) + .toList(); + expect( + restored.map( + (row) => providerDateTimeAsUtcInstant( + row.startDateTime, + row.startTimeZone, + ), + ), + unorderedEquals([ + DateTime.utc(2026, 1, 31, 9), + DateTime.utc(2026, 3, 31, 9), + DateTime.utc(2026, 5, 31, 9), + DateTime.utc(2026, 7, 31, 9), + ]), + ); + await repository.updateLocalEvent(approved); + await check(); + final client = GoogleCalendarApiClient( + httpClient: MockClient(server.respond), + baseUri: Uri.parse('https://calendar.example.test'), + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 1, 2), + ).replayDueOps(), + 1, + reason: (await database.select(database.pendingOps).get()) + .map((op) => op.lastErrorMessage) + .join('\n'), + ); + await check(); + expect( + (await database.select(database.calendarEvents).get()).where( + (row) => + !row.isDeleted && row.providerEventId.startsWith('local:series:'), + ), + isEmpty, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + for (final (drag, interruption) in [ + (false, 'none'), + (false, 'after-create'), + (true, 'none'), + ]) { + test( + drag + ? 'Google coordinator weekday drag keeps its recurrence through sync and restart' + : 'Google following clock edit keeps the final UNTIL occurrence ($interruption)', + () async { + final directory = await createPersistenceTestDirectory( + 'busymax-until-', + ); + AppDatabase open() => AppDatabase( + NativeDatabase(File('${directory.path}/fixture.sqlite')), + ); + var database = open(); + addTearDown(() async { + await database.close(); + await directory.delete(recursive: true); + }); + final server = _GoogleSeriesServer( + clockOnlyUntil: !drag, + noExceptions: drag, + ); + final client = GoogleCalendarApiClient( + httpClient: MockClient(server.respond), + baseUri: Uri.parse('https://calendar.example.test'), + ); + CalendarRepository repository() => CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 1), + ); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'fixture', + credentialKind: 'oauth', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + createdAtUtc: '2026-06-01T00:00:00Z', + updatedAtUtc: '2026-06-01T00:00:00Z', + ), + ); + await repository().upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Fixture', + timeZone: 'UTC', + accessRole: 'owner', + ), + ); + for (final raw in server.resources.values) { + await repository().upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('calendar', raw), + ); + } + final selected = (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.providerEventId == 'old-b'); + final detail = (await repository().loadEventDetail(selected.id))!; + final draft = EventEditorDraft.fromEventDetail(detail).copyWith( + start: DateTime.utc(2026, 6, drag ? 9 : 8, drag ? 9 : 10), + end: DateTime.utc(2026, 6, drag ? 9 : 8, drag ? 10 : 11), + recurringMutationScope: drag + ? RecurringEventMutationScope.entireSeries + : RecurringEventMutationScope.thisAndFuture, + ); + final plan = (await repository().prepareSeriesTimingChange(draft))!; + if (drag) { + final item = + (await ScheduleRepository(database).listItems( + range: ScheduleRange( + start: DateTime.utc(2026, 6), + end: DateTime.utc(2026, 7), + ), + )) + .whereType() + .singleWhere((item) => item.id == selected.id); + expect( + await ScheduleReschedulingCoordinator( + repository: repository(), + chooseScope: (_, _) async => + RecurringEventMutationScope.entireSeries, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (_) async {}, + ).commit( + ScheduleRescheduleRequest( + item: item, + interval: ScheduleInterval(draft.start!, draft.end!), + ), + ), + ScheduleRescheduleResult.saved, + ); + } else { + expect(plan.rule.untilRaw, '20260629T100000Z'); + await repository().updateLocalEvent(draft); + } + Future check() async { + final active = (await database.select(database.calendarEvents).get()) + .where( + (row) => + row.providerRecurringEventId != null && + !row.isDeleted && + !row.isCancelled, + ); + expect( + active.map( + (row) => providerDateTimeAsUtcInstant( + row.startDateTime, + row.startTimeZone, + ), + ), + unorderedEquals([ + if (drag) ...[ + for (final day in [2, 9, 16, 23, 30]) + DateTime.utc(2026, 6, day, 9), + ] else ...[ + DateTime.utc(2026, 6, 1, 9), + for (final day in [8, 15, 22, 29]) + DateTime.utc(2026, 6, day, 10), + ], + ]), + ); + } + + await check(); + await database.close(); + database = open(); + if (interruption == 'after-create') { + server.loseCreateResponse = true; + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 1), + ).replayDueOps(), + 0, + ); + expect( + await database.select(database.pendingOps).get(), + hasLength(1), + ); + await database.close(); + database = open(); + } + expect( + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 2), + ).replayDueOps(), + 1, + reason: (await database.select(database.pendingOps).get()) + .map((op) => op.lastErrorMessage) + .join('\n'), + ); + final replacement = + server.resources[drag ? 'master' : server.newMasterId]!; + expect( + (replacement['recurrence'] as List).single, + contains(drag ? 'BYDAY=TU' : 'UNTIL=20260629T100000Z'), + ); + expect( + (replacement['recurrence'] as List).single, + drag ? contains('COUNT=5') : isNot(contains('COUNT=')), + ); + expect(server.masterPatchCount, 1); + await check(); + final sync = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 2), + ); + await sync.fullSync(); + await sync.incrementalSync(); + await database.close(); + database = open(); + await check(); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + } + + for (final (scope, workflow) in [ + for (final scope in ['entire', 'following', 'following-first']) + (scope, 'editor'), + for (final scope in ['entire', 'following']) (scope, 'properties'), + for (final scope in ['entire', 'following']) (scope, 'drag'), + ]) { + final drag = workflow == 'drag'; + final properties = workflow == 'properties'; + final following = scope != 'entire'; + final split = scope == 'following'; + final first = scope == 'following-first'; + for (final interruption + in workflow != 'editor' + ? [ + 'none', + 'after-exception', + if (properties) ...[ + 'after-properties', + 'after-properties-conflict', + ], + ] + : first + ? ['after-master'] + : [ + 'none', + 'before-exception', + 'after-master', + 'after-exception', + 'after-cancellation', + 'during-identities', + if (following) 'after-create', + ]) { + test( + 'Google $workflow $scope edit preserves exception schedules and association ($interruption)', + () async { + final directory = await createPersistenceTestDirectory( + 'busymax-series-timing-', + ); + AppDatabase open() => AppDatabase( + NativeDatabase(File('${directory.path}/fixture.sqlite')), + ); + var database = open(); + addTearDown(() async { + await database.close(); + await directory.delete(recursive: true); + }); + Future reopen() async { + await database.close(); + database = open(); + } + + final server = _GoogleSeriesServer(nonTimingExceptions: properties); + final client = GoogleCalendarApiClient( + httpClient: MockClient(server.respond), + baseUri: Uri.parse('https://calendar.example.test'), + ); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'fixture-account', + credentialKind: 'oauth', + createdAtUtc: '2026-06-01T00:00:00Z', + updatedAtUtc: '2026-06-01T00:00:00Z', + email: const Value('me@example.test'), + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + ), + ); + CalendarRepository repository() => CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 1), + ); + await repository().upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Fixture', + timeZone: 'UTC', + accessRole: 'owner', + dataOwner: 'me@example.test', + ), + ); + for (final raw in server.resources.values) { + await repository().upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('calendar', raw), + ); + } + final selected = + (await database.select(database.calendarEvents).get()) + .singleWhere( + (row) => row.providerEventId == (first ? 'old-a' : 'old-b'), + ); + final detail = (await repository().loadEventDetail(selected.id))!; + final draft = EventEditorDraft.fromEventDetail(detail).copyWith( + start: DateTime.utc(2026, 6, first ? 2 : 9, 9), + end: DateTime.utc(2026, 6, first ? 2 : 9, 10), + recurringMutationScope: following + ? RecurringEventMutationScope.thisAndFuture + : RecurringEventMutationScope.entireSeries, + ); + if (drag) { + final item = + (await ScheduleRepository(database).listItems( + range: ScheduleRange( + start: DateTime.utc(2026, 6, 1), + end: DateTime.utc(2026, 7, 1), + ), + )) + .whereType() + .singleWhere((item) => item.id == selected.id); + expect( + await ScheduleReschedulingCoordinator( + repository: repository(), + chooseScope: (_, _) async => draft.recurringMutationScope, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (_) async {}, + ).commit( + ScheduleRescheduleRequest( + item: item, + interval: ScheduleInterval(draft.start!, draft.end!), + ), + ), + ScheduleRescheduleResult.saved, + ); + } else { + await repository().updateLocalEvent(draft); + } + Future checkOptimistic() async { + final active = + (await database.select(database.calendarEvents).get()) + .where( + (row) => + row.providerRecurringEventId != null && + !row.isDeleted && + !row.isCancelled, + ) + .toList(); + expect(active, hasLength(4)); + final exception = active.singleWhere( + (row) => row.title == 'Moved Wednesday', + ); + expect( + providerDateTimeAsUtcInstant( + exception.startDateTime, + exception.startTimeZone, + ), + DateTime.utc(2026, 6, drag ? 18 : 17, 13), + ); + expect( + providerDateTimeAsUtcInstant( + exception.endDateTime, + exception.endTimeZone, + ), + DateTime.utc(2026, 6, drag ? 18 : 17, 15), + ); + expect( + active + .where( + (row) => + row.title != 'Moved Wednesday' && + row.providerEventId != 'old-a', + ) + .map( + (row) => providerDateTimeAsUtcInstant( + row.startDateTime, + row.startTimeZone, + )!.weekday, + ), + everyElement(DateTime.tuesday), + ); + final first = active.singleWhere( + (row) => row.providerEventId == 'old-a', + ); + expect( + providerDateTimeAsUtcInstant( + first.startDateTime, + first.startTimeZone, + )!.weekday, + split ? DateTime.monday : DateTime.tuesday, + ); + } + + await checkOptimistic(); + await reopen(); + await checkOptimistic(); + server + ..failNextExceptionPatch = interruption == 'before-exception' + ..loseMasterResponse = interruption == 'after-master' + ..loseExceptionResponse = interruption == 'after-exception' + ..loseNonTimingResponse = interruption.startsWith( + 'after-properties', + ) + ..loseCancellationResponse = interruption == 'after-cancellation' + ..loseCreateResponse = interruption == 'after-create' + ..failLastIdentity = interruption == 'during-identities'; + Future replay(DateTime now) => CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => now, + ).replayDueOps(); + if (interruption != 'none') { + if (interruption == 'before-exception') { + await expectLater( + replay(DateTime.utc(2026, 6, 1)), + throwsA(isA()), + ); + } else { + expect(await replay(DateTime.utc(2026, 6, 1)), 0); + } + expect( + await database.select(database.pendingOps).get(), + hasLength(1), + ); + final recovery = await database + .select(database.pendingOps) + .getSingle(); + await expectLater( + PendingOpResolutionService( + database: database, + accountId: 'account', + calendarClient: client, + syncTasks: () async {}, + syncCalendar: () async {}, + ).discard(recovery.id), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('preserving its exceptions'), + ), + ), + ); + expect( + await database.select(database.pendingOps).get(), + hasLength(1), + ); + if (interruption == 'during-identities') { + expect( + (await database.select(database.calendarEvents).get()).any( + (row) => row.id == recovery.eventId, + ), + isTrue, + ); + expect(recovery.eventId, contains('new-b')); + } + await reopen(); + } + if (interruption == 'after-properties-conflict') { + server.resources['new-b']!.addAll({ + 'summary': 'Further remote edit', + 'etag': '"new-b-remote"', + }); + expect(await replay(DateTime.utc(2026, 6, 2)), 0); + final pending = await database + .select(database.pendingOps) + .getSingle(); + expect(pending.lastErrorCode, 'conflict'); + expect(pending.requestJson, contains('Customized title')); + expect( + server.resources['new-b']!['summary'], + 'Further remote edit', + ); + expect(server.resources['new-b']!['etag'], '"new-b-remote"'); + await reopen(); + expect(await replay(DateTime.utc(2026, 6, 3)), 0); + expect( + await database.select(database.pendingOps).get(), + hasLength(1), + ); + return; + } + expect( + await replay(DateTime.utc(2026, 6, 2)), + 1, + reason: (await database.select(database.pendingOps).get()) + .map((op) => op.lastErrorMessage) + .join('\n'), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + final moved = server.resources.values.singleWhere( + (raw) => + raw['summary'] == 'Moved Wednesday' && + raw['status'] != 'cancelled', + ); + expect( + DateTime.parse((moved['start'] as Map)['dateTime'] as String), + DateTime.utc(2026, 6, drag ? 18 : 17, 13), + ); + expect( + DateTime.parse((moved['end'] as Map)['dateTime'] as String), + DateTime.utc(2026, 6, drag ? 18 : 17, 15), + ); + expect( + (moved['originalStartTime'] as Map)['dateTime'], + '2026-06-16T09:00:00.000Z', + ); + expect( + moved['recurringEventId'], + split ? server.newMasterId : 'master', + ); + expect(moved['extendedProperties'], { + 'private': {'fixture': 'keep'}, + }); + expect(moved['reminders'], { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 35}, + ], + }); + expect(server.resources['new-d']!['status'], 'cancelled'); + expect(server.masterPatchCount, 1); + if (properties) { + final title = server.resources['new-b']!; + expect(title['summary'], 'Customized title'); + expect( + DateTime.parse((title['start'] as Map)['dateTime'] as String), + DateTime.utc(2026, 6, 9, 9), + ); + final reminder = server.resources['new-e']!; + expect( + reminder['reminders'], + server.resources['old-e']!['reminders'], + ); + expect( + DateTime.parse((reminder['start'] as Map)['dateTime'] as String), + DateTime.utc(2026, 6, 30, 9), + ); + for (final (id, day) in [('new-b', 9), ('new-e', 30)]) { + final patch = server.exceptionPatches[id]!; + expect( + DateTime.parse((patch['start'] as Map)['dateTime'] as String), + DateTime.utc(2026, 6, day, 9), + ); + expect( + DateTime.parse((patch['end'] as Map)['dateTime'] as String), + DateTime.utc(2026, 6, day, 10), + ); + } + } + final sync = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 2), + ); + await sync.fullSync(); + await sync.incrementalSync(); + await reopen(); + final active = (await database.select(database.calendarEvents).get()) + .where( + (row) => + row.providerRecurringEventId != null && + !row.isDeleted && + !row.isCancelled, + ) + .toList(); + expect(active, hasLength(4)); + final saved = active.singleWhere( + (row) => row.title == 'Moved Wednesday', + ); + expect(saved.providerOriginalStartKey, '2026-06-16T09:00:00.000Z'); + expect( + saved.providerRecurringEventId, + split ? server.newMasterId : 'master', + ); + expect( + providerDateTimeAsUtcInstant( + saved.startDateTime, + saved.startTimeZone, + ), + DateTime.utc(2026, 6, drag ? 18 : 17, 13), + ); + final ordinary = active.where((row) => row.id != saved.id); + expect( + ordinary.map( + (row) => providerDateTimeAsUtcInstant( + row.startDateTime, + row.startTimeZone, + )!.weekday, + ), + split + ? unorderedEquals([ + DateTime.monday, + DateTime.tuesday, + DateTime.tuesday, + ]) + : everyElement(DateTime.tuesday), + ); + expect(active.map((row) => row.syncStatus), everyElement('synced')); + if (properties) { + expect( + active.singleWhere((row) => row.providerEventId == 'new-b').title, + 'Customized title', + ); + expect( + jsonDecode( + active + .singleWhere((row) => row.providerEventId == 'new-e') + .remindersJson!, + ), + server.resources['old-e']!['reminders'], + ); + } + }, + ); + } + } +} + +/// HTTP fixtures use opaque IDs and explicit recurrence identities. Resolving +/// an instance must go through events.instances; no provider ID encoding is +/// assumed by either the test or production code. +class _GoogleSeriesServer { + _GoogleSeriesServer({ + this.monthly = false, + this.clockOnlyUntil = false, + this.nonTimingExceptions = false, + this.noExceptions = false, + }) { + resources['master'] = { + 'id': 'master', + 'iCalUID': 'series@example.test', + 'summary': 'Weekly', + 'etag': '"master-before"', + 'updated': '2026-05-30T00:00:00Z', + 'organizer': {'self': true, 'email': 'me@example.test'}, + 'start': {'dateTime': '2026-06-01T09:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-01T10:00:00Z', 'timeZone': 'UTC'}, + 'recurrence': ['RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=5'], + }; + if (monthly) { + resources['master']!.addAll({ + 'start': {'dateTime': '2026-01-31T09:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-01-31T10:00:00Z', 'timeZone': 'UTC'}, + 'recurrence': ['RRULE:FREQ=MONTHLY;BYMONTHDAY=31;COUNT=4'], + }); + for (final entry in { + 'old-a': 1, + 'old-b': 3, + 'old-c': 5, + 'old-d': 7, + }.entries) { + resources[entry.key] = _instance( + entry.key, + 'master', + 1, + date: DateTime.utc(2026, entry.value, 31, 9), + ); + } + return; + } + for (final entry in { + 'old-a': 1, + 'old-b': 8, + 'old-c': 15, + 'old-d': 22, + 'old-e': 29, + }.entries) { + resources[entry.key] = _instance(entry.key, 'master', entry.value); + } + if (clockOnlyUntil) { + resources['master']!['recurrence'] = [ + 'RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20260629T090000Z', + ]; + return; + } + if (noExceptions) return; + if (nonTimingExceptions) { + resources['old-b']!['summary'] = 'Customized title'; + resources['old-e']!['reminders'] = { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 45}, + ], + }; + } + resources['old-c']!.addAll({ + 'summary': 'Moved Wednesday', + 'start': {'dateTime': '2026-06-17T13:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-17T15:00:00Z', 'timeZone': 'UTC'}, + 'extendedProperties': { + 'private': {'fixture': 'keep'}, + }, + 'reminders': { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 35}, + ], + }, + }); + resources['old-d']!['status'] = 'cancelled'; + } + final bool monthly; + final bool clockOnlyUntil; + final bool nonTimingExceptions; + final bool noExceptions; + final resources = >{}; + final exceptionPatches = >{}; + int masterPatchCount = 0; + bool failNextExceptionPatch = false; + bool loseMasterResponse = false; + bool loseExceptionResponse = false; + bool loseNonTimingResponse = false; + bool loseCancellationResponse = false; + bool loseCreateResponse = false; + bool failLastIdentity = false; + String? newMasterId; + bool get following => newMasterId != null; + + static Map _instance( + String id, + String series, + int day, { + DateTime? date, + }) { + final start = date ?? DateTime.utc(2026, 6, day, 9); + return { + 'id': id, + 'iCalUID': 'series@example.test', + 'recurringEventId': series, + 'summary': 'Weekly', + 'status': 'confirmed', + 'etag': '"$id-before"', + 'organizer': {'self': true, 'email': 'me@example.test'}, + 'originalStartTime': {'dateTime': start.toIso8601String()}, + 'start': {'dateTime': start.toIso8601String(), 'timeZone': 'UTC'}, + 'end': { + 'dateTime': start.add(const Duration(hours: 1)).toIso8601String(), + 'timeZone': 'UTC', + }, + }; + } + + void _generate(String series, {required bool following}) { + if (clockOnlyUntil) { + final master = resources[series]!; + final start = DateTime.parse( + (master['start'] as Map)['dateTime'] as String, + ); + final rule = (master['recurrence'] as List).single as String; + final until = DateTime.parse( + RegExp(r'UNTIL=([^;]+)').firstMatch(rule)![1]!, + ); + // An independent bounded fixture for the documented weekly UTC rule: + // UNTIL includes candidates equal to the boundary, never later ones. + for (final entry in { + 'new-b': 8, + 'new-c': 15, + 'new-d': 22, + 'new-e': 29, + }.entries) { + final candidate = DateTime.utc(2026, 6, entry.value, start.hour); + if (!candidate.isBefore(start) && !candidate.isAfter(until)) { + resources[entry.key] = _instance( + entry.key, + series, + entry.value, + date: candidate, + ); + } + } + for (final id in ['old-b', 'old-c', 'old-d', 'old-e']) { + resources[id]!['status'] = 'cancelled'; + } + return; + } + if (monthly) { + for (final entry in { + 'new-a': 2, + 'new-b': 3, + 'new-c': 4, + 'new-d': 5, + }.entries) { + resources[entry.key] = _instance( + entry.key, + series, + 1, + date: DateTime.utc(2026, entry.value, 1, 9), + ); + } + for (final id in ['old-a', 'old-b', 'old-c', 'old-d']) { + resources[id]!['status'] = 'cancelled'; + } + return; + } + for (final entry in { + 'new-a': 2, + 'new-b': 9, + 'new-c': 16, + 'new-d': 23, + 'new-e': 30, + }.entries) { + if (following && entry.key == 'new-a') continue; + resources[entry.key] = _instance(entry.key, series, entry.value); + } + for (final id in ['old-a', 'old-b', 'old-c', 'old-d', 'old-e']) { + if (following && id == 'old-a') continue; + // Entire-series updates may retain an old detached exception. The + // replacement must be verified before retiring that exact old resource. + if (!following && id == 'old-c' && !noExceptions) continue; + resources[id]!['status'] = 'cancelled'; + } + } + + http.Response _json(Object? body, [int status = 200]) => http.Response( + jsonEncode(body), + status, + headers: {'content-type': 'application/json'}, + ); + Future respond(http.Request request) async { + final segments = request.url.pathSegments; + if (segments.last == 'calendarList') { + return _json({ + 'items': [ + { + 'id': 'calendar', + 'summary': 'Fixture', + 'timeZone': 'UTC', + 'accessRole': 'owner', + 'selected': true, + }, + ], + }); + } + final eventIndex = segments.indexOf('events'); + expect(eventIndex, isNonNegative); + final suffix = segments.skip(eventIndex + 1).toList(); + if (suffix.isEmpty) { + if (request.method == 'POST') { + final body = Map.from(jsonDecode(request.body) as Map); + expect( + (body['recurrence'] as List).single, + contains(clockOnlyUntil ? 'BYDAY=MO' : 'BYDAY=TU'), + ); + newMasterId = body['id'] as String; + if (resources.containsKey(newMasterId)) { + return _json({ + 'error': {'code': 409, 'message': 'Already exists'}, + }, 409); + } + resources[newMasterId!] = { + ...body, + 'etag': '"split"', + 'iCalUID': 'new-series@example.test', + }; + _generate(newMasterId!, following: true); + if (loseCreateResponse) { + loseCreateResponse = false; + throw http.ClientException('Lost create response'); + } + return _json(resources[newMasterId!]); + } + if (request.url.queryParameters['iCalUID'] != null) { + expect(request.url.queryParameters['singleEvents'], 'false'); + expect(request.url.queryParameters['showDeleted'], 'true'); + return _json({ + 'items': [ + resources['master'], + if (!monthly && !clockOnlyUntil && !noExceptions) ...[ + resources['old-c'], + resources['old-d'], + if (nonTimingExceptions) ...[ + resources['old-b'], + resources['old-e'], + ], + ], + ], + }); + } + return _json({ + 'items': resources.values + .where((raw) => raw['recurringEventId'] != null) + .toList(), + 'nextSyncToken': 'cursor', + }); + } + final id = suffix.first; + if (suffix.length == 2 && suffix.last == 'instances') { + final original = request.url.queryParameters['originalStart']; + final values = resources.values.where( + (raw) => + raw['recurringEventId'] == id && + (original == null || + DateTime.parse( + (raw['originalStartTime'] as Map)['dateTime'] as String, + ) == + DateTime.parse(original)), + ); + return _json({'items': values.toList()}); + } + if (request.method == 'GET') { + if (id == 'new-e' && failLastIdentity) { + failLastIdentity = false; + throw http.ClientException('Interrupted local identity reconciliation'); + } + final raw = resources[id]; + return raw == null + ? _json({ + 'error': {'code': 404, 'message': 'Missing'}, + }, 404) + : _json(raw); + } + if (request.method == 'DELETE') { + expect(request.headers['if-match'], resources[id]!['etag']); + resources[id]!['status'] = 'cancelled'; + resources[id]!['etag'] = '"$id-cancelled"'; + if (loseCancellationResponse) { + loseCancellationResponse = false; + throw http.ClientException('Lost cancellation response'); + } + return http.Response('', 204); + } + expect(request.method, 'PATCH'); + expect(request.headers['if-match'], resources[id]!['etag']); + if (id == 'master') { + masterPatchCount++; + final body = Map.from(jsonDecode(request.body) as Map); + resources[id]!.addAll({ + ...body, + 'etag': '"master-after"', + 'updated': '2026-06-01T00:00:00Z', + }); + if (body.containsKey('start')) { + expect( + (body['recurrence'] as List).single, + contains(monthly ? 'BYMONTHDAY=1' : 'BYDAY=TU'), + ); + _generate('master', following: false); + } + if (loseMasterResponse) { + loseMasterResponse = false; + throw http.ClientException('Lost master response'); + } + return _json(resources[id]); + } + final body = Map.from(jsonDecode(request.body) as Map); + exceptionPatches[id] = body; + expect(body, isNot(contains('originalStartTime'))); + expect(body, isNot(contains('recurringEventId'))); + if (failNextExceptionPatch) { + failNextExceptionPatch = false; + return _json({ + 'error': {'code': 500, 'message': 'Transient fixture error'}, + }, 500); + } + resources[id]!.addAll({...body, 'etag': '"$id-restored"'}); + // A provider response may normalize equivalent RFC3339 representations. + // Preserve the written request separately so retry tests cross this real + // transport boundary instead of relying on byte-identical echoed JSON. + for (final field in ['start', 'end']) { + final temporal = resources[id]![field]; + if (temporal is Map && temporal['dateTime'] is String) { + resources[id]![field] = { + ...temporal, + 'dateTime': DateTime.parse(temporal['dateTime'] as String) + .toUtc() + .toIso8601String() + .replaceFirst('.000Z', 'Z'), + }; + } + } + if (loseExceptionResponse) { + loseExceptionResponse = false; + throw http.ClientException('Lost exception response'); + } + if (loseNonTimingResponse && id == 'new-b') { + loseNonTimingResponse = false; + throw http.ClientException('Lost title-only exception response'); + } + return _json(resources[id]); + } +} diff --git a/test/features/sync/calendar_sync_engine_test.dart b/test/features/sync/calendar_sync_engine_test.dart index 6010079f..e27f87a9 100644 --- a/test/features/sync/calendar_sync_engine_test.dart +++ b/test/features/sync/calendar_sync_engine_test.dart @@ -16,11 +16,13 @@ import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; +import 'package:busymax/src/features/sync/domain_sync_schedule.dart'; import 'package:busymax/src/features/sync/account_sync_operations.dart'; import 'package:busymax/src/features/sync/cloud_calendar_range_coverage_service.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; +import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; @@ -42,6 +44,237 @@ void main() { await database.close(); }); + test('shared primary access loss retains cached events and reports Calendar sync failure', () async { + await _insertAccount(database, provider: BusyProvider.microsoft); + final repository = CalendarRepository(database: database); + final calendarId = const MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner@example.test', + graphCalendarId: 'qa', + ).sourceCalendarId; + await repository.upsertSource( + accountId: 'account', + source: CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + summary: 'Cached shared QA calendar', + readOnly: false, + ), + ); + await repository.upsertEvent( + accountId: 'account', + event: CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + providerEventId: 'qa-event', + title: 'Cached QA event', + startDateTime: '2026-10-20T10:00:00Z', + endDateTime: '2026-10-20T11:00:00Z', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + ), + ); + final client = MicrosoftCalendarApiClient( + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + httpClient: MockClient( + (request) async => request.url.path.contains('/users/') + ? http.Response( + '{"error":{"code":"ErrorAccessDenied","message":"synthetic"}}', + 403, + ) + : http.Response('{"value":[]}', 200), + ), + ); + final engine = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + ); + await expectLater( + DomainSyncPolicy(database).run( + 'account', + SyncDomain.calendar, + pull: engine.fullSync, + deferred: engine.dispatchPendingWrites, + ), + throwsA(isA()), + ); + expect(await database.select(database.domainSyncSchedules).get(), isEmpty); + final source = await database.select(database.calendarSources).getSingle(); + expect(source.readOnly, isTrue); + expect(source.accessRole, 'unavailable'); + expect(source.isDeleted, isFalse); + final event = await database.select(database.calendarEvents).getSingle(); + expect(event.title, 'Cached QA event'); + expect(event.isDeleted, isFalse); + }); + + test('Google ID-only removal preserves pending intent and isolates accounts and calendars', () async { + await _insertAccount(database, provider: BusyProvider.google); + final account = await database.select(database.accounts).getSingle(); + await database + .into(database.accounts) + .insert( + account + .toCompanion(false) + .copyWith( + id: const Value('other-account'), + providerAccountId: const Value('other-provider-account'), + ), + ); + final repository = CalendarRepository(database: database); + final ids = []; + for (final identity in [ + ('account', 'cal-1'), + ('account', 'cal-2'), + ('other-account', 'cal-1'), + ]) { + await repository.upsertSource( + accountId: identity.$1, + source: CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: identity.$2, + summary: 'Work', + ), + ); + await repository.upsertEvent( + accountId: identity.$1, + event: googleCalendarEventFromJson(identity.$2, { + 'id': 'same-id', + 'recurringEventId': 'master', + 'originalStartTime': {'dateTime': '2026-07-15T09:00:00Z'}, + 'summary': 'Original', + 'organizer': {'self': true}, + 'start': {'dateTime': '2026-07-15T09:00:00Z'}, + 'end': {'dateTime': '2026-07-15T10:00:00Z'}, + }), + ); + ids.add( + (await database.select(database.calendarEvents).get()) + .singleWhere( + (row) => + row.accountId == identity.$1 && + row.providerCalendarId == identity.$2, + ) + .id, + ); + } + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(ids.first))!, + ).copyWith( + title: 'Pending local edit', + recurringMutationScope: RecurringEventMutationScope.singleOccurrence, + ), + ); + final pending = await database.select(database.pendingOps).getSingle(); + await repository.markGoogleRemovedEventDeleted( + accountId: 'account', + providerCalendarId: 'cal-1', + providerEventId: 'same-id', + ); + final rows = await database.select(database.calendarEvents).get(); + expect(rows, hasLength(3)); + expect(rows.every((row) => !row.isDeleted), isTrue); + expect( + rows.singleWhere((row) => row.id == ids.first).title, + 'Pending local edit', + ); + expect(await database.select(database.pendingOps).getSingle(), pending); + }); + + test('Google incremental ID-only cancellation retires the cached occurrence identity idempotently', () async { + const source = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Work', + ); + await _insertAccount(database, provider: BusyProvider.google); + await _insertSource(database, source); + final repository = CalendarRepository(database: database); + for (final id in ['occurrence', 'other']) { + await repository.upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('cal-1', { + 'id': id, + 'recurringEventId': 'master', + 'originalStartTime': {'dateTime': '2026-07-15T09:00:00Z'}, + 'summary': id, + 'start': {'dateTime': '2026-07-15T09:00:00Z'}, + 'end': {'dateTime': '2026-07-15T10:00:00Z'}, + 'reminders': { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 10}, + ], + }, + }), + ); + } + await NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 7, 15, 8), + ).rebuildUpcomingEventNotifications('account'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(2), + ); + await repository.saveSyncState( + accountId: 'account', + provider: BusyProvider.google, + syncKind: 'events', + calendarSourceId: CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + ), + rangeStart: DateTime.utc(2025, 7).toIso8601String(), + rangeEnd: DateTime.utc(2028, 8).toIso8601String(), + cursorKind: 'google_sync_token', + cursorValue: 'prior', + full: true, + stateJson: '{"singleEvents":true,"version":1}', + ); + final marker = googleCalendarEventFromJson('cal-1', { + 'id': 'occurrence', + 'status': 'cancelled', + }); + final client = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: [source], + pages: [ + CalendarSyncPageDto(events: [marker], nextSyncTokenOrDeltaLink: 'next'), + CalendarSyncPageDto(events: [marker], nextSyncTokenOrDeltaLink: 'last'), + ], + ); + final engine = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 15, 8), + ); + await engine.incrementalSync(); + await engine.incrementalSync(); + final rows = await database.select(database.calendarEvents).get(); + expect(rows, hasLength(2)); + expect( + rows.singleWhere((row) => row.providerEventId == 'occurrence').isDeleted, + isTrue, + ); + expect( + rows.singleWhere((row) => row.providerEventId == 'other').isDeleted, + isFalse, + ); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + expect(client.syncCalls.map((call) => call.syncTokenOrDeltaLink), [ + 'prior', + 'next', + ]); + }); + for (final incremental in [false, true]) { for (final cancelled in [false, true]) { test( diff --git a/test/features/sync/cloud_calendar_range_coordination_test.dart b/test/features/sync/cloud_calendar_range_coordination_test.dart index 4eb79b54..5b17359d 100644 --- a/test/features/sync/cloud_calendar_range_coordination_test.dart +++ b/test/features/sync/cloud_calendar_range_coordination_test.dart @@ -13,6 +13,7 @@ import 'package:busymax/src/features/connectivity/network_connectivity_service.d import 'package:busymax/src/features/notifications/notification_reconciler.dart'; import 'package:busymax/src/features/sync/account_sync_operations.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; +import 'package:busymax/src/features/sync/domain_sync_schedule.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:busymax/src/microsoft_todo/oauth/microsoft_oauth_service.dart'; @@ -25,6 +26,53 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test( + '$provider deferred writes retain a failed pull until recovery', + () async { + final fixture = await _Fixture.create(provider: provider); + addTearDown(fixture.dispose); + final operations = fixture.container.read( + accountSyncOperationsProvider, + ); + final activity = fixture.container.read(accountSyncActivityProvider); + await operations.syncCalendar(_accountId, full: true); + final previous = + (await fixture.database + .select(fixture.database.domainSyncSchedules) + .getSingle()) + .lastSuccessfulPullUtc; + final failure = StateError('Synthetic calendar pull failure'); + fixture.client.syncFailure = failure; + await expectLater( + operations.syncCalendar(_accountId, full: true), + throwsA(same(failure)), + ); + expect(activity.failure(_accountId, 'calendar'), same(failure)); + + fixture.client.syncFailure = null; + final discoveries = fixture.client.calendarDiscoveryCalls; + await withSyncTrigger( + SyncTrigger.localMutation, + () => operations.syncCalendar(_accountId, full: false), + ); + expect(fixture.client.calendarDiscoveryCalls, discoveries); + expect(activity.failure(_accountId, 'calendar'), same(failure)); + expect( + (await fixture.database + .select(fixture.database.domainSyncSchedules) + .getSingle()) + .lastSuccessfulPullUtc, + previous, + ); + + await operations.syncCalendar(_accountId, full: true); + expect(fixture.client.calendarDiscoveryCalls, greaterThan(discoveries)); + expect(activity.failure(_accountId, 'calendar'), isNull); + }, + ); + } + test( 'range retrieval completes before a newer baseline sync imports events', () async { @@ -367,6 +415,8 @@ final class _HeldCalendarClient implements CloudCalendarClient { List rangeEvents = []; bool holdRange = false; bool holdSync = false; + Object? syncFailure; + int calendarDiscoveryCalls = 0; @override BusyProvider get provider => source.provider; @@ -378,7 +428,11 @@ final class _HeldCalendarClient implements CloudCalendarClient { : microsoftCalendarProviderCapabilities; @override - Future> listCalendars() async => [source]; + Future> listCalendars() async { + calendarDiscoveryCalls++; + if (syncFailure case final failure?) throw failure; + return [source]; + } @override Future> listEvents({ diff --git a/test/features/sync/microsoft_series_timing_integration_test.dart b/test/features/sync/microsoft_series_timing_integration_test.dart new file mode 100644 index 00000000..78995e81 --- /dev/null +++ b/test/features/sync/microsoft_series_timing_integration_test.dart @@ -0,0 +1,473 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/core/time/provider_date_time.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/features/sync/calendar_pending_ops_replayer.dart'; +import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_event_rescheduling.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import '../../support/persistence_test_directory.dart'; + +void main() { + for (final limitation in [ + 'none', + 'properties', + 'drag', + 'cancellation', + 'attachment', + 'meeting', + 'boundary', + ]) { + test( + 'Microsoft series timing preserves provider state ($limitation)', + () async { + final directory = await createPersistenceTestDirectory( + 'busymax-ms-series-', + ); + AppDatabase open() => AppDatabase( + NativeDatabase(File('${directory.path}/fixture.sqlite')), + ); + var database = open(); + addTearDown(() async { + await database.close(); + await directory.delete(recursive: true); + }); + Future reopen() async { + await database.close(); + database = open(); + } + + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com/common', + providerAccountId: 'fixture', + credentialKind: 'oauth', + authState: const Value('signed_in'), + email: const Value('me@example.test'), + calendarsEnabled: const Value(true), + createdAtUtc: '2026-06-01T00:00:00Z', + updatedAtUtc: '2026-06-01T00:00:00Z', + ), + ); + final server = _MicrosoftSeriesServer(limitation); + final client = MicrosoftCalendarApiClient( + httpClient: MockClient(server.respond), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: _zone, + ); + CalendarRepository repository() => CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 6, 1), + ); + await repository().upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'calendar', + summary: 'Fixture', + timeZone: _zone, + ), + ); + for (final resource in server.resources.values) { + await repository().upsertEvent( + accountId: 'account', + event: microsoftCalendarEventFromJson('calendar', resource), + ); + } + final selected = (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.providerEventId == 'old-b'); + final detail = (await repository().loadEventDetail(selected.id))!; + final draft = EventEditorDraft.fromEventDetail(detail).copyWith( + start: providerCivilDateTime(DateTime.utc(2026, 6, 9, 9)), + end: providerCivilDateTime(DateTime.utc(2026, 6, 9, 10)), + recurringMutationScope: RecurringEventMutationScope.entireSeries, + ); + final drag = limitation == 'drag'; + if (drag) { + final item = + (await ScheduleRepository(database).listItems( + range: ScheduleRange( + start: DateTime.utc(2026, 6), + end: DateTime.utc(2026, 7), + ), + )) + .whereType() + .singleWhere((item) => item.id == selected.id); + expect( + await ScheduleReschedulingCoordinator( + repository: repository(), + chooseScope: (_, _) async => + RecurringEventMutationScope.entireSeries, + chooseGuestUpdates: (_) async => throw StateError('No guests'), + requestSync: (_) async {}, + ).commit( + ScheduleRescheduleRequest( + item: item, + interval: ScheduleInterval( + providerWallTimeToInstant(draft.start!, _zone), + providerWallTimeToInstant(draft.end!, _zone), + ), + ), + ), + ScheduleRescheduleResult.saved, + ); + } else { + await repository().updateLocalEvent(draft); + } + await reopen(); + final result = await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 2), + ).replayDueOps(); + if (!['none', 'properties', 'drag'].contains(limitation)) { + expect(result, 0); + expect( + server.patchCount, + 0, + reason: 'Provider limitations must be checked before the master is changed.', + ); + final pending = await database + .select(database.pendingOps) + .getSingle(); + expect( + pending.lastErrorCode, + 'series_exception_preservation_required', + ); + expect(pending.nextAttemptAtUtc, startsWith('9999-12-31')); + final original = server.resources['old-c']!; + expect( + (original['start'] as Map)['dateTime'], + limitation == 'boundary' + ? '2026-06-09T13:00:00' + : '2026-06-17T13:00:00', + ); + await reopen(); + expect( + await database.select(database.pendingOps).get(), + hasLength(1), + ); + return; + } + expect( + result, + 1, + reason: (await database.select(database.pendingOps).get()) + .map((op) => op.lastErrorMessage) + .join('\n'), + ); + final exception = server.resources['new-c']!; + expect(exception['originalStart'], '2026-06-16T16:00:00.000Z'); + expect( + DateTime.parse((exception['start'] as Map)['dateTime'] as String), + DateTime(2026, 6, drag ? 18 : 17, 13), + ); + expect( + DateTime.parse((exception['end'] as Map)['dateTime'] as String), + DateTime(2026, 6, drag ? 18 : 17, 15), + ); + expect(exception['body'], { + 'contentType': 'html', + 'content': '

Keep exception details

', + }); + expect(exception['categories'], ['Exception category']); + expect(server.resources['old-c']!['isCancelled'], isTrue); + if (limitation == 'properties') { + expect(server.resources['new-b']!['subject'], 'Customized title'); + expect( + (server.resources['new-b']!['start'] as Map)['dateTime'], + '2026-06-09T09:00:00.000', + ); + expect( + (server.resources['new-e']!['start'] as Map)['dateTime'], + '2026-06-30T09:00:00.000', + ); + expect(server.resources['new-e']!['isReminderOn'], isTrue); + expect(server.resources['new-e']!['reminderMinutesBeforeStart'], 45); + } + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 2), + ).fullSync(); + await reopen(); + final active = (await database.select(database.calendarEvents).get()) + .where( + (row) => + row.providerRecurringEventId != null && + !row.isDeleted && + !row.isCancelled, + ) + .toList(); + expect(active, hasLength(5)); + final moved = active.singleWhere( + (row) => row.title == 'Moved Wednesday', + ); + expect(moved.providerOriginalStartKey, '2026-06-16T16:00:00.000Z'); + expect( + providerDateTimeAsUtcInstant( + moved.startDateTime, + moved.startTimeZone, + ), + DateTime.utc(2026, 6, drag ? 18 : 17, 20), + ); + expect( + providerDateTimeAsUtcInstant(moved.endDateTime, moved.endTimeZone), + DateTime.utc(2026, 6, drag ? 18 : 17, 22), + ); + expect( + active + .where((row) => row.id != moved.id) + .map( + (row) => providerDateTimeAsCivilTime( + row.startDateTime, + row.startTimeZone, + )!.weekday, + ), + everyElement(DateTime.tuesday), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + if (limitation == 'properties') { + expect( + active.singleWhere((row) => row.providerEventId == 'new-b').title, + 'Customized title', + ); + expect( + jsonDecode( + active + .singleWhere((row) => row.providerEventId == 'new-e') + .remindersJson!, + ), + {'isReminderOn': true, 'reminderMinutesBeforeStart': 45}, + ); + } + }, + ); + } +} + +const _zone = 'Pacific Standard Time'; + +class _MicrosoftSeriesServer { + _MicrosoftSeriesServer(this.limitation) { + resources['master'] = { + 'id': 'master', + 'type': 'seriesMaster', + 'subject': 'Weekly', + 'changeKey': 'master-before', + 'lastModifiedDateTime': '2026-05-30T00:00:00Z', + 'isOrganizer': true, + 'isAllDay': false, + 'start': {'dateTime': '2026-06-01T09:00:00', 'timeZone': _zone}, + 'end': {'dateTime': '2026-06-01T10:00:00', 'timeZone': _zone}, + 'body': {'contentType': 'html', 'content': '

Keep master body

'}, + 'recurrence': { + 'pattern': { + 'type': 'weekly', + 'interval': 1, + 'daysOfWeek': ['monday'], + 'firstDayOfWeek': 'monday', + }, + 'range': { + 'type': 'numbered', + 'startDate': '2026-06-01', + 'numberOfOccurrences': 5, + 'recurrenceTimeZone': _zone, + }, + }, + }; + for (final entry in { + 'old-a': 1, + 'old-b': 8, + 'old-c': 15, + 'old-d': 22, + 'old-e': 29, + }.entries) { + resources[entry.key] = _instance(entry.key, entry.value); + } + if (limitation == 'properties') { + resources['old-b']!.addAll({ + 'type': 'exception', + 'subject': 'Customized title', + }); + resources['old-e']!.addAll({ + 'type': 'exception', + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 45, + }); + } + resources['old-c']!.addAll({ + 'type': 'exception', + 'subject': 'Moved Wednesday', + 'start': { + 'dateTime': limitation == 'boundary' + ? '2026-06-09T13:00:00' + : '2026-06-17T13:00:00', + 'timeZone': _zone, + }, + 'end': { + 'dateTime': limitation == 'boundary' + ? '2026-06-09T15:00:00' + : '2026-06-17T15:00:00', + 'timeZone': _zone, + }, + 'body': { + 'contentType': 'html', + 'content': '

Keep exception details

', + }, + 'categories': ['Exception category'], + 'hasAttachments': limitation == 'attachment', + 'isOnlineMeeting': limitation == 'meeting', + }); + } + final String limitation; + final resources = >{}; + int patchCount = 0; + static Map _instance(String id, int day) => { + 'id': id, + 'type': 'occurrence', + 'seriesMasterId': 'master', + 'subject': 'Weekly', + 'isAllDay': false, + 'changeKey': '$id-before', + 'lastModifiedDateTime': '2026-05-30T00:00:00Z', + 'originalStart': providerWallTimeToInstant( + DateTime.utc(2026, 6, day, 9), + _zone, + ).toIso8601String(), + 'start': { + 'dateTime': providerWallTimeIso8601String(DateTime.utc(2026, 6, day, 9)), + 'timeZone': _zone, + }, + 'end': { + 'dateTime': providerWallTimeIso8601String(DateTime.utc(2026, 6, day, 10)), + 'timeZone': _zone, + }, + 'body': {'contentType': 'html', 'content': '

Keep master body

'}, + }; + http.Response _json(Object? body, [int status = 200]) => http.Response( + jsonEncode(body), + status, + headers: {'content-type': 'application/json'}, + ); + Future respond(http.Request request) async { + final segments = request.url.pathSegments; + if (segments.last == 'calendars') { + return _json({ + 'value': [ + {'id': 'calendar', 'name': 'Fixture', 'canEdit': true}, + ], + }); + } + if (segments.last == 'calendarView') { + return _json({ + 'value': resources.values + .where( + (raw) => + raw['seriesMasterId'] == 'master' && + raw['isCancelled'] != true, + ) + .toList(), + }); + } + final eventIndex = segments.indexOf('events'); + expect(eventIndex, isNonNegative); + final suffix = segments.skip(eventIndex + 1).toList(); + final id = suffix.first; + if (suffix.last == 'instances') { + return _json({ + 'value': resources.values + .where( + (raw) => + raw['seriesMasterId'] == 'master' && + raw['isCancelled'] != true, + ) + .toList(), + }); + } + if (request.method == 'GET') { + final raw = resources[id]; + if (request.url.queryParameters.containsKey(r'$expand')) { + expect(request.url.queryParameters[r'$expand'], 'exceptionOccurrences'); + expect( + request.url.queryParameters[r'$select'], + contains('cancelledOccurrences'), + ); + return _json({ + ...raw!, + 'exceptionOccurrences': resources.values + .where( + (raw) => + raw['type'] == 'exception' && raw['isCancelled'] != true, + ) + .toList(), + 'cancelledOccurrences': limitation == 'cancellation' + ? ['opaque-cancelled-occurrence'] + : [], + }); + } + return _json(raw); + } + if (request.method == 'DELETE') { + expect(id, 'old-c'); + resources[id]!['isCancelled'] = true; + return http.Response('', 204); + } + expect(request.method, 'PATCH'); + patchCount++; + final body = Map.from(jsonDecode(request.body) as Map); + expect(body, isNot(contains('originalStart'))); + expect(body, isNot(contains('seriesMasterId'))); + if (id == 'master') { + expect(body, isNot(contains('body'))); + expect(((body['recurrence'] as Map)['pattern'] as Map)['daysOfWeek'], [ + 'tuesday', + ]); + expect( + ((body['recurrence'] as Map)['range'] as Map)['startDate'], + '2026-06-02', + ); + for (final entry in { + 'new-a': 2, + 'new-b': 9, + 'new-c': 16, + 'new-d': 23, + 'new-e': 30, + }.entries) { + resources[entry.key] = _instance(entry.key, entry.value); + } + for (final id in ['old-a', 'old-b', 'old-d', 'old-e']) { + resources[id]!['isCancelled'] = true; + } + } else { + resources[id]!['type'] = 'exception'; + } + resources[id]!.addAll({ + ...body, + 'changeKey': '$id-after', + 'lastModifiedDateTime': '2026-06-01T00:00:00Z', + }); + return _json(resources[id]); + } +} diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index dc6d20f9..092c57c8 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -15,6 +15,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/sync/pending_ops_replayer.dart'; import 'package:busymax/src/features/sync/sync_engine.dart'; +import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/schedule/schedule_sidebar_order.dart'; @@ -48,6 +49,171 @@ void main() { await database.close(); }); + for (final chained in [false, true]) { + test( + 'blocked Google move and dependent edit keep one optimistic task during pulls (chain: $chained)', + () async { + final account = await database.select(database.accounts).getSingle(); + await database + .into(database.accounts) + .insert( + account + .toCompanion(false) + .copyWith( + id: const Value('other-account'), + providerAccountId: const Value('google-other-account'), + ), + ); + await database.taskListsDao.upsertTaskList( + _taskList('list-1').copyWith(accountId: const Value('other-account')), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'Other account', + ).copyWith(accountId: const Value('other-account')), + ); + for (final list in ['list-2', 'list-3', 'unrelated']) { + await database.taskListsDao.upsertTaskList(_taskList(list)); + } + final baseline = _taskDto( + 'task-1', + title: 'Original', + updated: DateTime.utc(2026, 6, 4), + ); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: baseline.title, + updatedUtc: baseline.updated!.toIso8601String(), + rawJson: jsonEncode(baseline.rawJson), + ), + ); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + destinationTaskListId: 'list-2', + ), + ); + if (chained) { + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-2', + taskId: 'task-1', + destinationTaskListId: 'list-3', + ), + ); + } + final destination = chained ? 'list-3' : 'list-2'; + await repository.patchTask( + destination, + 'task-1', + const TaskPatchInput({'title': 'Local dependent edit'}), + ); + apiClient.persistTaskPatches = true; + apiClient.remoteTask = _taskDto( + 'task-1', + title: 'Remote edit', + updated: DateTime.utc(2026, 6, 4, 0, 5), + ); + apiClient.remoteTaskListsPage = TaskListsPageDto( + items: [ + for (final list in ['list-1', 'list-2', 'list-3', 'unrelated']) + _taskListDto(list), + ], + rawJson: const {}, + ); + apiClient.remoteTasksPages['list-1'] = TasksPageDto( + items: [apiClient.remoteTask!], + rawJson: const {}, + ); + apiClient.remoteTasksPages['unrelated'] = TasksPageDto( + items: [_taskDto('task-1', title: 'Unrelated')], + rawJson: const {}, + ); + final engine = SyncEngine( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ); + for (var round = 0; round < 2; round++) { + await engine.fullSync(); + final rows = (await database.select(database.tasks).get()) + .where( + (task) => + task.accountId == 'account' && + task.taskListId != 'unrelated', + ) + .toList(); + expect(rows, hasLength(1)); + expect(rows.single.taskListId, destination); + expect(rows.single.title, 'Local dependent edit'); + expect(rows.single.pendingMove, isTrue); + expect( + (await database.select(database.tasks).get()) + .singleWhere((task) => task.accountId == 'other-account') + .title, + 'Other account', + ); + final pending = await database.select(database.pendingOps).get(); + expect(pending, hasLength(chained ? 3 : 2)); + expect( + pending + .singleWhere((op) => op.taskListId == 'list-1') + .lastErrorCode, + 'conflict', + ); + } + final move = (await database.select(database.pendingOps).get()) + .singleWhere((op) => op.taskListId == 'list-1'); + // The provider returns to the checked baseline; retry through the existing recovery control. + apiClient.remoteTask = baseline; + await PendingOpResolutionService( + database: database, + apiClient: apiClient, + accountId: 'account', + syncTasks: () => PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + syncCalendar: () async {}, + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).retryNow(move.id); + expect(await database.select(database.pendingOps).get(), isEmpty); + apiClient.remoteTasksPages['list-1'] = const TasksPageDto( + items: [], + rawJson: {}, + ); + apiClient.remoteTasksPages[destination] = TasksPageDto( + items: [apiClient.remoteTask!], + rawJson: const {}, + ); + await engine.fullSync(); + final finalRows = (await database.select(database.tasks).get()) + .where( + (task) => + task.accountId == 'account' && task.taskListId != 'unrelated', + ) + .toList(); + expect(finalRows, hasLength(1)); + expect(finalRows.single.taskListId, destination); + expect(finalRows.single.localDirty, isFalse); + expect(finalRows.single.title, 'Local dependent edit'); + }, + ); + } + test('replay blocks legacy Docs-assigned note mutation', () async { await database.tasksDao.upsertTask( _task( @@ -4894,6 +5060,7 @@ class _CommitInterruption extends QueryInterceptor { } class _FakeTaskRemoteClient implements TaskRemoteClient { + final remoteTasksPages = {}; TaskDto? createdTask; final List createdTasks = []; final calls = []; @@ -5131,7 +5298,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { bool showAssigned = false, }) async { taskListPageUpdatedMins.add(updatedMin); - return remoteTasksPage; + return remoteTasksPages[taskListId] ?? remoteTasksPage; } } diff --git a/test/features/sync/sync_engine_test.dart b/test/features/sync/sync_engine_test.dart index 5270a2cb..782b0dc0 100644 --- a/test/features/sync/sync_engine_test.dart +++ b/test/features/sync/sync_engine_test.dart @@ -16,6 +16,8 @@ import 'package:busymax/src/features/notifications/desktop_notification_service. import 'package:busymax/src/features/notifications/due_today_notification_scheduler.dart'; import 'package:busymax/src/features/sync/account_sync_operations.dart'; import 'package:busymax/src/features/sync/sync_engine.dart'; +import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_client.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_models.dart'; import 'package:busymax/src/features/tasks/domain/task_checklist_item.dart'; @@ -44,6 +46,76 @@ void main() { } }); + for (final action in [ + 'edit task', + 'delete task', + 'rename list', + 'delete list', + ]) { + test( + 'remote import serializes a concurrent $action through the real executor', + () async { + await database.close(); + final gate = _ImportInterleaving( + action.contains('task') ? 'tasks' : 'task_lists', + ); + database = AppDatabase(NativeDatabase.memory().interceptWith(gate)); + await _insertAccount(database); + await database.taskListsDao.upsertTaskList(_localTaskList('list-1')); + await database.tasksDao.upsertTask(_localTask('task-1')); + apiClient.taskListsPages = [ + TaskListsPageDto(items: [_taskListDto('list-1')], rawJson: const {}), + ]; + apiClient.taskPages['list-1'] = [ + TasksPageDto( + items: [_taskDto('task-1', title: 'Remote')], + rawJson: const {}, + ), + ]; + gate.armed = true; + final sync = SyncEngine( + database: database, + apiClient: apiClient, + accountId: 'account', + ).fullSync(); + await gate.beforeImport.future; + final tasks = TasksRepository(database: database, accountId: 'account'); + final lists = TaskListsRepository( + database: database, + accountId: 'account', + ); + final mutation = switch (action) { + 'edit task' => tasks.patchTask( + 'list-1', + 'task-1', + const TaskPatchInput({'title': 'Local edit'}), + ), + 'delete task' => tasks.deleteTask('list-1', 'task-1'), + 'rename list' => lists.renameTaskList('list-1', 'Local rename'), + _ => lists.deleteTaskList('list-1'), + }; + await gate.localAttempt.future; + // Outside a transaction the local mutation can commit before the stale + // import. Inside one, SQLite serializes it behind the paused import. + if (!gate.importInTransaction) await mutation; + gate.release.complete(); + await Future.wait([sync, mutation]); + expect(await database.select(database.pendingOps).get(), hasLength(1)); + if (action.contains('task')) { + final row = await database.select(database.tasks).getSingle(); + expect(row.localDirty, isTrue); + expect(row.pendingDelete, action == 'delete task'); + if (action == 'edit task') expect(row.title, 'Local edit'); + } else { + final row = await database.select(database.taskLists).getSingle(); + expect(row.localDirty, isTrue); + expect(row.pendingDelete, action == 'delete list'); + if (action == 'rename list') expect(row.title, 'Local rename'); + } + }, + ); + } + for (final incremental in [false, true]) { for (final completed in [false, true]) { test( @@ -1024,6 +1096,37 @@ void main() { ); } +class _ImportInterleaving extends QueryInterceptor { + _ImportInterleaving(this.table); + final String table; + bool armed = false; + bool paused = false; + bool importInTransaction = false; + final beforeImport = Completer(); + final localAttempt = Completer(); + final release = Completer(); + @override + Future runInsert( + QueryExecutor executor, + String statement, + List args, + ) async { + if (armed && !paused && statement.startsWith('INSERT INTO "$table"')) { + paused = true; + importInTransaction = executor is TransactionExecutor; + beforeImport.complete(); + await release.future; + } + return executor.runInsert(statement, args); + } + + @override + Future ensureOpen(QueryExecutor executor, QueryExecutorUser user) { + if (paused && !localAttempt.isCompleted) localAttempt.complete(); + return executor.ensureOpen(user); + } +} + class FakeTaskRemoteClient implements TaskRemoteClient, TaskChecklistRemoteClient { var taskListsPages = []; diff --git a/test/ical/ical_import_service_test.dart b/test/ical/ical_import_service_test.dart index 2d5e377d..9fb0f025 100644 --- a/test/ical/ical_import_service_test.dart +++ b/test/ical/ical_import_service_test.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:busymax/src/core/time/provider_date_time.dart'; + import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/calendar_providers/cloud_calendar_client.dart'; @@ -45,6 +47,133 @@ void main() { tearDown(() async => database.close()); + for (final durationProvider in [ + BusyProvider.google, + BusyProvider.microsoft, + ]) { + for (final example in [ + ('20260308T013000', 'PT2H', DateTime.utc(2026, 3, 8, 11, 30)), + ('20260308T013000', 'P1D', DateTime.utc(2026, 3, 9, 8, 30)), + ('20260308T013000', 'PT24H', DateTime.utc(2026, 3, 9, 9, 30)), + ('20261101T003000', 'PT2H', DateTime.utc(2026, 11, 1, 9, 30)), + ('20261101T003000', 'P1D', DateTime.utc(2026, 11, 2, 8, 30)), + ('20261101T003000', 'PT24H', DateTime.utc(2026, 11, 2, 7, 30)), + ]) { + test( + '$durationProvider conversion import resolves ${example.$1} + ${example.$2} in the event zone', + () async { + if (durationProvider == BusyProvider.microsoft) { + await _seedMicrosoftDestination(database); + } + final preview = importService.parsePreview( + utf8.encode( + [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax Test//EN', + 'BEGIN:VEVENT', + 'UID:dst-duration', + 'SUMMARY:Duration', + 'DTSTART;TZID=America/Vancouver:${example.$1}', + 'DURATION:${example.$2}', + 'END:VEVENT', + 'END:VCALENDAR', + '', + ].join('\r\n'), + ), + ); + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()) + .singleWhere((source) => source.provider == durationProvider), + ); + expect(report.queued, 1); + final request = jsonDecode( + (await database.select(database.pendingOps).getSingle()) + .requestJson, + ) as Map; + expect( + providerDateTimeAsUtcInstant( + request['end'] as String, + request['endTimeZone'] as String, + ), + example.$3, + ); + expect(request['startTimeZone'], 'America/Vancouver'); + if (durationProvider == BusyProvider.microsoft) { + final payload = microsoftEventMutationToJson( + CalendarEventMutation( + endDateTime: request['end'] as String, + endTimeZone: request['endTimeZone'] as String, + ), + ); + final wireEnd = payload['end'] as Map; + expect( + providerDateTimeAsUtcInstant( + wireEnd['dateTime'] as String, + wireEnd['timeZone'] as String, + ), + example.$3, + ); + } else { + expect(request['endTimeZone'], 'America/Vancouver'); + Map? sent; + final api = GoogleCalendarApiClient( + baseUri: Uri.parse('https://www.googleapis.com'), + httpClient: MockClient((httpRequest) async { + if (httpRequest.method == 'GET') { + return http.Response('{"items":[]}', 200); + } + expect(httpRequest.method, 'POST'); + expect(httpRequest.url.path, endsWith('/events/import')); + sent = Map.from( + jsonDecode(httpRequest.body) as Map, + ); + return http.Response( + jsonEncode({ + ...sent!, + 'id': 'imported-duration', + 'etag': '"created"', + 'updated': '2026-08-29T00:00:00Z', + }), + 200, + ); + }), + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'google-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ).replayDueOps(), + 1, + ); + final end = sent!['end'] as Map; + // Google permits a wall timestamp when timeZone is supplied; + // an explicit numeric offset must still use RFC 3339 syntax. + expect(end['timeZone'], 'America/Vancouver'); + expect( + end['dateTime'], + matches( + RegExp( + r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$', + ), + ), + ); + expect( + providerDateTimeAsUtcInstant( + end['dateTime'] as String, + end['timeZone'] as String, + ), + example.$3, + ); + } + }, + ); + } + } + test( 'exported sparse Google all-day cancellation imports its original date', () async { diff --git a/test/platform/android/android_authorization_broker_test.dart b/test/platform/android/android_authorization_broker_test.dart index c49738e2..776c5ce6 100644 --- a/test/platform/android/android_authorization_broker_test.dart +++ b/test/platform/android/android_authorization_broker_test.dart @@ -3,12 +3,15 @@ import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/secrets/secret_store.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax_android_platform/busymax_android_platform.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; +import 'package:http/http.dart' as http; +import 'package:busymax/src/core/http/shared_graph_transport.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -26,6 +29,202 @@ void main() { config: BuildConfig.forAndroid(), ); + for (final claims in [ + null, + '{"access_token":{"example":{"essential":true}}}', + ]) { + test( + 'an intervening Microsoft ${claims == null ? 'scope' : 'claims variant'} cannot consume a rejected request refresh', + () async { + final nativeCalls = >[]; + final cache = {}; + messenger.setMockMethodCallHandler(channel, (call) async { + final arguments = Map.from(call.arguments as Map); + nativeCalls.add(arguments); + final contacts = (arguments['scopes'] as List).any( + (scope) => scope.toString().endsWith('Contacts.Read'), + ); + final key = + '${contacts ? 'contacts' : 'ordinary'}${arguments['claims'] == null ? '' : '-claims'}'; + final cacheKey = '${arguments['accountId']}/$key'; + if (arguments['forceRefresh'] == true) cache[cacheKey] = 'fresh-$key'; + return { + 'accessToken': cache[cacheKey] ?? 'rejected-$key', + 'nativeAccountId': 'native', + 'scopes': [ + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + if (contacts) 'Contacts.Read', + ], + }; + }); + final auth = broker(InMemorySecretStore()); + final headers = []; + final transport = BusyMaxGraphTransport( + httpClient: MockClient((request) async { + headers.add(request.headers['authorization']); + return http.Response( + headers.length == 1 + ? '{"error":{"code":"InvalidAuthenticationToken","message":"Rejected"}}' + : '{}', + headers.length == 1 ? 401 : 200, + ); + }), + authorizationHeader: (_) => auth.microsoftContactsAuthorizationHeader( + 'microsoft:account', + claims: claims, + ), + recoverUnauthorized: () async { + await auth.recoverUnauthorized( + BusyProvider.microsoft, + 'microsoft:account', + ); + await auth.authorizationHeader( + BusyProvider.microsoft, + 'microsoft:other', + ); + expect(nativeCalls.last['forceRefresh'], isFalse); + if (claims == null) { + await auth.authorizationHeader( + BusyProvider.microsoft, + 'microsoft:account', + ); + } else { + await auth.microsoftContactsAuthorizationHeader( + 'microsoft:account', + ); + } + }, + ); + expect( + (await transport.request( + 'GET', + Uri.parse('https://graph.microsoft.com/v1.0/me/contacts'), + )).statusCode, + 200, + ); + final requestedKey = 'contacts${claims == null ? '' : '-claims'}'; + expect(headers, [ + 'Bearer rejected-$requestedKey', + 'Bearer fresh-$requestedKey', + ]); + expect(nativeCalls.last['forceRefresh'], isTrue); + await auth.microsoftContactsAuthorizationHeader( + 'microsoft:account', + claims: claims, + ); + expect(nativeCalls.last['forceRefresh'], isFalse); + }, + ); + } + + for (final rejectRetry in [false, true]) { + test( + 'Microsoft 401 forces the native acquisition used by the bounded retry ($rejectRetry)', + () async { + var cachedToken = 'rejected'; + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if ((call.arguments as Map)['forceRefresh'] == true) { + cachedToken = 'fresh'; + } + return { + 'accessToken': cachedToken, + 'nativeAccountId': 'native', + 'scopes': ['User.Read', 'Tasks.ReadWrite', 'Calendars.ReadWrite'], + }; + }); + final auth = broker(InMemorySecretStore()); + final headers = []; + final transport = BusyMaxGraphTransport( + httpClient: MockClient((request) async { + headers.add(request.headers['authorization']); + if (headers.length == 1 || rejectRetry) { + return http.Response( + '{"error":{"code":"InvalidAuthenticationToken","message":"Rejected"}}', + 401, + ); + } + return http.Response('{}', 200); + }), + authorizationHeader: (_) => auth.authorizationHeader( + BusyProvider.microsoft, + 'microsoft:account', + ), + recoverUnauthorized: () => auth.recoverUnauthorized( + BusyProvider.microsoft, + 'microsoft:account', + ), + ); + expect( + (await transport.request( + 'GET', + Uri.parse('https://graph.microsoft.com/v1.0/me'), + )).statusCode, + rejectRetry ? 401 : 200, + ); + expect(headers, ['Bearer rejected', 'Bearer fresh']); + expect( + calls.where( + (call) => (call.arguments as Map)['forceRefresh'] == true, + ), + hasLength(1), + ); + await auth.authorizationHeader( + BusyProvider.microsoft, + 'microsoft:account', + ); + expect((calls.last.arguments as Map)['forceRefresh'], isNot(true)); + }, + ); + } + + test( + 'Google 401 invalidates the rejected native token before retry', + () async { + var token = 'rejected-google'; + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + if (call.method == 'clearRejectedGoogleToken') { + expect((call.arguments as Map)['token'], 'rejected-google'); + token = 'fresh-google'; + return null; + } + expect(call.method, 'authorizeGoogleSilent'); + return { + 'accessToken': token, + 'nativeAccountId': 'native', + 'scopes': [googleTasksReadWriteScope, googleCalendarReadWriteScope], + }; + }); + final auth = broker(InMemorySecretStore()); + final headers = []; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + headers.add(request.headers['authorization']); + return http.Response( + headers.length == 1 ? '{"error":{"code":401}}' : '{"items":[]}', + headers.length == 1 ? 401 : 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () => + auth.authorizationHeader(BusyProvider.google, 'google:account'), + unauthorizedRefreshProvider: () => + auth.recoverUnauthorized(BusyProvider.google, 'google:account'), + ); + expect(await client.listCalendars(), isEmpty); + expect(headers, ['Bearer rejected-google', 'Bearer fresh-google']); + expect( + calls.where((call) => call.method == 'clearRejectedGoogleToken'), + hasLength(1), + ); + }, + ); + test( 'silent Google authorization does not invent requested grants', () async { diff --git a/test/platform/android/android_backup_rules_test.dart b/test/platform/android/android_backup_rules_test.dart new file mode 100644 index 00000000..07bb0fa4 --- /dev/null +++ b/test/platform/android/android_backup_rules_test.dart @@ -0,0 +1,40 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xml/xml.dart'; + +void main() { + for (final rules in [ + ('backup_rules.xml', 'full-backup-content'), + ('data_extraction_rules.xml', 'cloud-backup'), + ('data_extraction_rules.xml', 'device-transfer'), + ]) { + test( + '${rules.$1} ${rules.$2} excludes private support files and SQLite sidecars', + () { + final xml = XmlDocument.parse( + File('android/app/src/main/res/xml/${rules.$1}').readAsStringSync(), + ); + final section = xml.findAllElements(rules.$2).single; + bool excluded(String domain, String path) => + section.findElements('exclude').any((rule) { + if (rule.getAttribute('domain') != domain) return false; + final root = rule.getAttribute('path')!; + return root == '.' || root == path || path.startsWith('$root/'); + }); + // path_provider_android returns getFilesDir() for application support; + // BusyMax opens busymax.sqlite there, outside Android's database domain. + for (final suffix in ['', '-wal', '-shm', '-journal']) { + expect( + excluded('file', 'busymax.sqlite$suffix'), + isTrue, + reason: suffix, + ); + } + expect(excluded('database', 'plugin.db'), isTrue); + expect(excluded('sharedpref', 'settings.xml'), isTrue); + expect(excluded('root', 'flutter_secure_storage'), isTrue); + }, + ); + } +} diff --git a/test/platform/android/android_notification_reconciliation_test.dart b/test/platform/android/android_notification_reconciliation_test.dart index a0598183..b057464f 100644 --- a/test/platform/android/android_notification_reconciliation_test.dart +++ b/test/platform/android/android_notification_reconciliation_test.dart @@ -1,6 +1,7 @@ import 'package:busymax/src/android/android_notifications.dart'; import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/core/time/stored_temporal_projection.dart'; +import 'package:busymax/src/dav/storage/dav_object_repository.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:drift/drift.dart' hide isNotNull, isNull; @@ -56,6 +57,142 @@ void main() { exactAlarmCapability: () async => exact, ); + for (final kind in [ + 'start-only', + 'due-only', + 'start-and-due', + 'floating', + 'zoned', + 'completed', + 'cancelled', + ]) { + test( + 'Android summary uses the real DAV deadline and lifecycle ($kind)', + () async { + settings = settings.copyWith(notifyDueToday: true); + final notifications = service(); + await notifications.initialize(timeZoneId: 'Etc/UTC'); + final today = _today().replaceAll('-', ''); + final localDay = DateTime.parse(_today()); + final tomorrow = DateTime( + localDay.year, + localDay.month, + localDay.day + 1, + ).toIso8601String().substring(0, 10).replaceAll('-', ''); + final eligible = ![ + 'start-only', + 'completed', + 'cancelled', + ].contains(kind); + const href = '/dav/work/'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'dav:test', + provider: 'nextcloud', + authority: 'https://qa.example.test', + providerAccountId: 'test', + credentialKind: 'nextcloud_app_password', + authState: const Value('signed_in'), + createdAtUtc: '', + updatedAtUtc: '', + ), + ); + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: 'dav-work', + accountId: 'dav:test', + displayName: 'Work', + hrefKey: href, + requestUri: 'https://qa.example.test$href', + supportedComponentMask: const Value(2), + taskProjectionEnabled: const Value(true), + createdAtUtc: '', + updatedAtUtc: '', + ), + ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'dav:test', + id: 'dav-task-list-dav-work', + title: 'Work', + davCollectionId: const Value('dav-work'), + rawJson: '{}', + createdLocalAtUtc: '', + updatedLocalAtUtc: '', + ), + ); + const objectHref = '${href}task.ics'; + final raw = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax Test//EN', + 'BEGIN:VTODO', + 'UID:deadline-test', + 'SUMMARY:Scheduled', + if (kind == 'floating') + 'DTSTART:${today}T080000' + else if (kind == 'zoned') + 'DTSTART;TZID=Pacific/Kiritimati:${tomorrow}T000000' + else if (kind != 'due-only') + 'DTSTART;VALUE=DATE:$today', + if (kind == 'floating') + 'DUE:${today}T090000' + else if (kind == 'zoned') + 'DUE;TZID=Pacific/Kiritimati:${tomorrow}T003000' + else if (kind != 'start-only') + 'DUE;VALUE=DATE:$today', + if (kind == 'completed') ...[ + 'STATUS:COMPLETED', + 'PERCENT-COMPLETE:100', + 'COMPLETED:${today}T000000Z', + ], + if (kind == 'cancelled') 'STATUS:CANCELLED', + 'END:VTODO', + 'END:VCALENDAR', + '', + ].join('\r\n'); + await DavObjectRepository(database: database).commit( + DavCollectionCommit( + accountId: 'dav:test', + collectionId: 'dav-work', + provider: BusyProvider.nextcloud, + objects: [ + DavPreparedObject.parse( + hrefKey: objectHref, + requestUri: Uri.parse('https://qa.example.test$objectHref'), + etag: '"one"', + contentType: 'text/calendar', + rawIcsBody: raw, + ), + ], + deletedHrefKeys: const {}, + completeMembership: true, + membershipHrefKeys: const {objectHref}, + finalCursorKind: 'dav_sync_token', + finalCursorValue: 'one', + baselineGeneration: 1, + completedAtUtc: DateTime.now().toUtc(), + projectionRangeStartUtc: DateTime.utc(localDay.year), + projectionRangeEndUtc: DateTime.utc(localDay.year + 1), + ), + ); + expect( + (await database.select(database.tasks).getSingle()).dueUtc, + isNotNull, + ); + await notifications.reconcile(); + expect( + await database.select(database.androidDailySummarySchedules).get(), + hasLength(eligible ? 1 : 0), + ); + }, + ); + } + test( 'reconciliation preserves a delivered notification still visible', () async { diff --git a/test/platform/windows/windows_account_workspace_routing_test.dart b/test/platform/windows/windows_account_workspace_routing_test.dart index 8cb0f34b..2cbc200f 100644 --- a/test/platform/windows/windows_account_workspace_routing_test.dart +++ b/test/platform/windows/windows_account_workspace_routing_test.dart @@ -147,8 +147,8 @@ void main() { expect(find.byType(WindowsSettingsPage), findsOneWidget); if (method == DesktopConnectionMethod.custom) { await _pumpConnectionPage(tester); - await tester.ensureVisible(find.text('Connect')); - await tester.tap(find.text('Connect')); + await tester.ensureVisible(find.text('Reconnect this account')); + await tester.tap(find.text('Reconnect this account')); await _pumpConnectionPage(tester); expect(find.byType(ContentDialog), findsNothing); for (var i = 0; i < 40; i++) { diff --git a/test/schedule/schedule_task_projection_test.dart b/test/schedule/schedule_task_projection_test.dart new file mode 100644 index 00000000..b48c682d --- /dev/null +++ b/test/schedule/schedule_task_projection_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_client.dart'; +import 'package:busymax/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart'; +import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; + +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_projection.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/process_time_zone.dart'; + +void main() { + group('native task schedule projection', () { + late AppDatabase database; + late ProcessTimeZone zone; + setUp(() async { + zone = ProcessTimeZone()..set('UTC'); + database = AppDatabase.memoryForTests(); + for (final account in ['a', 'b']) { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: account, + provider: 'microsoft', + authority: 'https://login.microsoftonline.com/common', + providerAccountId: account, + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: '', + updatedAtUtc: '', + ), + ); + for (final list in ['one', 'two']) { + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: account, + id: list, + title: list, + rawJson: '{}', + createdLocalAtUtc: '', + updatedLocalAtUtc: '', + ), + ); + } + } + }); + tearDown(() async { + await database.close(); + zone.restore(); + }); + + Future task({ + String account = 'a', + String list = 'one', + String id = 'task', + String? start, + String? due, + String? timeZone, + }) async { + final wire = { + 'id': id, + 'title': '$account/$list', + 'status': 'notStarted', + if (start != null) + 'startDateTime': {'dateTime': start, 'timeZone': timeZone}, + if (due != null) 'dueDateTime': {'dateTime': due, 'timeZone': timeZone}, + }; + final adapter = MicrosoftTodoTaskRemoteClient( + client: MicrosoftTodoRestApiClient( + httpClient: MockClient( + (request) async => http.Response(jsonEncode(wire), 200), + ), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer qa', + ), + defaultTimeZone: 'UTC', + ); + final dto = await adapter.getTask(taskListId: list, taskId: id); + await database.tasksDao.upsertTask(taskFromDto(account, list, dto, '')); + } + + test('unfiltered All Tasks hides a pending-deleted populated list with account isolation', () async { + await task(); + await task(list: 'two'); + await task(account: 'b'); + await TaskListsRepository( + database: database, + accountId: 'a', + ).deleteTaskList('one'); + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 7, 1)), + filters: const ScheduleFilters(showNoDateTasks: true), + ); + expect( + items.whereType().map( + (item) => '${item.accountId}/${item.sourceId}', + ), + unorderedEquals(['a/two', 'b/one']), + ); + expect(await database.select(database.tasks).get(), hasLength(3)); + expect( + (await database.select(database.pendingOps).getSingle()).operation, + 'delete_task_list', + ); + }); + + for (final dueOnly in [false, true]) { + for (final example in [ + ( + '2026-07-01T09:00:00', + 'America/Vancouver', + DateTime.utc(2026, 7, 1, 16), + ), + ( + '2026-07-01T09:00:00', + 'Pacific Standard Time', + DateTime.utc(2026, 7, 1, 16), + ), + ( + '2026-07-01T09:00:00+02:00', + 'America/Vancouver', + DateTime.utc(2026, 7, 1, 7), + ), + ( + '2026-07-01T23:00:00', + 'America/Vancouver', + DateTime.utc(2026, 7, 2, 6), + ), + ('2026-07-01', 'America/Vancouver', DateTime.utc(2026, 7, 1)), + ]) { + test( + 'Microsoft ${dueOnly ? 'due fallback' : 'start'} resolves ${example.$1} ${example.$2}', + () async { + await task( + start: dueOnly ? null : example.$1, + due: dueOnly ? example.$1 : null, + timeZone: example.$2, + ); + final items = await ScheduleRepository(database) + .listItems(range: ScheduleRange.day(example.$3.toLocal())); + final item = items.single as TaskScheduleItem; + expect(item.start?.toUtc(), example.$3); + if (dueOnly) expect(item.due?.toUtc(), example.$3); + }, + ); + } + } + + for (final day in [ + DateTime(2026, 3, 8), + DateTime(2026, 7, 1), + DateTime(2026, 11, 1), + ]) { + test('loaded multi-day all-day tasks end at civil midnight on $day', () async { + zone.set('America/Vancouver'); + final localDay = DateTime(day.year, day.month, day.day); + final nextDay = DateTime(day.year, day.month, day.day + 1); + final date = + '${day.year}-${day.month.toString().padLeft(2, '0')}-${day.day.toString().padLeft(2, '0')}'; + await task(due: date); + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange( + start: localDay, + end: DateTime(day.year, day.month, day.day + 7), + ), + ); + expect(items.single.start, localDay); + expect(items.single.end, nextDay); + expect(ScheduleProjection.itemsForDay(items, localDay), hasLength(1)); + expect(ScheduleProjection.itemsForDay(items, nextDay), isEmpty); + }); + } + }, skip: !(Platform.isLinux || Platform.isMacOS)); +} diff --git a/test/support/contacts_fixture.dart b/test/support/contacts_fixture.dart index cd1ce14c..062cd94e 100644 --- a/test/support/contacts_fixture.dart +++ b/test/support/contacts_fixture.dart @@ -20,6 +20,12 @@ final class ContactsFixture implements ContactsAuthorizationBroker { Duration syncInterval = const Duration(days: 1), bool realDav = false, BusyMaxLinkedDavCredential? linkedDavCredential, + Future Function(String)? + readLinkedDavCredential, + Future Function(String)? readAuthorizationGeneration, + Future Function(String, AuthorizationCancellation)? + reconnectLinkedDavAccount, + Future Function(String)? readDavSecret, }) : accounts = AccountsRepository(database: database), store = BusyMaxContactsStore(database) { controller = BusyMaxContactsController( @@ -27,14 +33,17 @@ final class ContactsFixture implements ContactsAuthorizationBroker { accounts: accounts, authorization: this, httpClient: MockClient(respond), - readDavSecret: (id) async => davSecrets[id], + readDavSecret: readDavSecret ?? (id) async => davSecrets[id], writeDavSecret: (id, value) async { davSecrets[id] = value; }, deleteDavSecret: (id) async { davSecrets.remove(id); }, - readLinkedDavCredential: (_) async => linkedDavCredential, + readLinkedDavCredential: + readLinkedDavCredential ?? (_) async => linkedDavCredential, + readAuthorizationGeneration: readAuthorizationGeneration, + reconnectLinkedDavAccount: reconnectLinkedDavAccount, launchBrowser: (_) async => true, syncInterval: syncInterval, providerFactory: realDav ? null : provider, @@ -69,6 +78,7 @@ final class ContactsFixture implements ContactsAuthorizationBroker { AuthorizationCancellation? cancellation, Future Function()? persistContacts, + bool forceAuthorization = false, }) async { if (consentPause != null) { await Future.any([ diff --git a/test/tool/local_snap_release_test.py b/test/tool/local_snap_release_test.py index 3f9b7610..fe03471e 100644 --- a/test/tool/local_snap_release_test.py +++ b/test/tool/local_snap_release_test.py @@ -48,7 +48,7 @@ def source_runner(self, args, cwd, **kwargs): if args[1:3] == ['rev-parse', 'HEAD']: return 'a' * 40 if args[1:3] == ['branch', '--show-current']: - return 'release/v0.3.1' + return 'release/v0.3.2' return '' def bundle(self, root): @@ -58,7 +58,7 @@ def bundle(self, root): p = root / name p.parent.mkdir(parents=True, exist_ok=True) p.write_bytes((' '.join(self.config().values())).encode() if name == 'lib/libapp.so' else b'fixture') - (root / 'data/flutter_assets/version.json').write_text('{"app_name":"busymax","version":"0.3.1"}') + (root / 'data/flutter_assets/version.json').write_text('{"app_name":"busymax","version":"0.3.2"}') os.chmod(root / 'busymax', 0o755) def test_valid_desktop_derives_only_managed_fields(self): @@ -113,18 +113,18 @@ def runner(args, cwd, **kwargs): if args[1] == 'ls-files': return 'client_secret_download.json' return self.source_runner(args, cwd, **kwargs) with patch.object(release, 'run', side_effect=runner): - with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'a'*40, '0.3.1') + with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'a'*40, '0.3.2') def test_wrong_source_version_rejected(self): self.source_files() for old in ['0.2.4', '0.3.0']: (self.source / 'pubspec.yaml').write_text(f'version: {old}\n') with patch.object(release, 'run', side_effect=self.source_runner): - with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'a'*40, '0.3.1') + with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'a'*40, '0.3.2') def test_wrong_revision_rejected(self): with patch.object(release, 'run', side_effect=self.source_runner): - with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'b'*40, '0.3.1') + with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'b'*40, '0.3.2') def test_existing_workspace_and_shared_repository_untouched(self): workspace = self.root / 'existing'; workspace.mkdir() @@ -145,7 +145,7 @@ def test_missing_bundle_rejected(self): def test_old_flutter_version_is_rejected(self): bundle = self.root / 'bundle'; self.bundle(bundle) (bundle / 'data/flutter_assets/version.json').write_text('{"app_name":"busymax","version":"0.3.0"}') - with self.assertRaises(release.ReleaseError): release.bundle_gate(bundle, self.config(), '0.3.1') + with self.assertRaises(release.ReleaseError): release.bundle_gate(bundle, self.config(), '0.3.2') def test_unconfigured_compilation_rejected(self): bundle = self.root / 'bundle'; self.bundle(bundle) @@ -154,7 +154,7 @@ def test_unconfigured_compilation_rejected(self): def test_missing_final_snap_rejected(self): with self.assertRaises(release.ReleaseError): - release.package_gate(self.root / 'missing.snap', self.root, '0.3.1', self.config(), self.source) + release.package_gate(self.root / 'missing.snap', self.root, '0.3.2', self.config(), self.source) def package_tree(self, tree): self.bundle(tree) @@ -163,24 +163,24 @@ def package_tree(self, tree): 'share/icons/hicolor/scalable/apps/io.busystack.busymax.svg']: p = tree / name; p.parent.mkdir(parents=True, exist_ok=True); p.write_text('fixture') p = tree / 'share/metainfo/io.busystack.busymax.metainfo.xml'; p.parent.mkdir(parents=True) - p.write_text('') + p.write_text('') p = tree / 'meta/snap.yaml'; p.parent.mkdir() (tree / 'meta/gui').mkdir(); (tree / 'meta/gui/busymax.desktop').write_text('fixture') - p.write_text('name: busymax\nversion: 0.3.1\nbase: core24\ngrade: devel\nconfinement: strict\napps:\n busymax:\n command: busymax\n autostart: io.busystack.busymax-autostart.desktop\n') + p.write_text('name: busymax\nversion: 0.3.2\nbase: core24\ngrade: devel\nconfinement: strict\napps:\n busymax:\n command: busymax\n autostart: io.busystack.busymax-autostart.desktop\n') def test_packaged_metadata_verified(self): snap = self.root / 'busymax.snap'; snap.touch() self.package_tree(self.root / 'inspection') with patch.object(release, 'run'): - release.package_gate(snap, self.root, '0.3.1', self.config(), self.source) + release.package_gate(snap, self.root, '0.3.2', self.config(), self.source) with self.assertRaises(release.ReleaseError): release.package_gate(snap, self.root, '0.3.0', self.config(), self.source) (self.root / 'inspection/meta/snap.yaml').write_text('confinement: classic') with patch.object(release, 'run'): - with self.assertRaises(release.ReleaseError): release.package_gate(snap, self.root, '0.3.1', self.config(), self.source) + with self.assertRaises(release.ReleaseError): release.package_gate(snap, self.root, '0.3.2', self.config(), self.source) def test_unexpected_output_artifacts_rejected(self): snap = self.root / 'busymax.snap'; snap.touch(); (self.root / 'other.snap').touch() - with self.assertRaises(release.ReleaseError): release.package_gate(snap, self.root, '0.3.1', self.config(), self.source) + with self.assertRaises(release.ReleaseError): release.package_gate(snap, self.root, '0.3.2', self.config(), self.source) def test_upload_and_destructive_modes_absent(self): script = (ROOT / 'tool/release/build_snap_local.py').read_text() @@ -192,7 +192,7 @@ def mocked_build(self, failure=None): self.source_files() workspace = self.root / 'workspace' options = argparse.Namespace(source=self.source, workspace=workspace, google_oauth=self.credentials, - local_config=None, revision='a'*40, version='0.3.1', flutter=self.root/'flutter') + local_config=None, revision='a'*40, version='0.3.2', flutter=self.root/'flutter') seen = [] def runner(args, cwd, **kwargs): seen.append([str(a) for a in args]) @@ -208,7 +208,7 @@ def runner(args, cwd, **kwargs): self.assertFalse(configuration.is_relative_to(workspace / 'busymax')) self.bundle(workspace/'busymax/build/linux/x64/release/bundle') if args[:2] == ['snapcraft', 'pack'] and failure != 'snap': - (Path(cwd) / args[-1] / 'busymax_0.3.1_amd64.snap').write_bytes(b'fixture-snap') + (Path(cwd) / args[-1] / 'busymax_0.3.2_amd64.snap').write_bytes(b'fixture-snap') if args[0] == 'unsquashfs': self.package_tree(Path(args[3])) if 'tool/release/verify_snap_runtime.sh' in args: if failure == 'runtime': raise release.ReleaseError('Runtime failure.') diff --git a/tool/release/build_snap_local.py b/tool/release/build_snap_local.py index 125e5804..b37ec864 100644 --- a/tool/release/build_snap_local.py +++ b/tool/release/build_snap_local.py @@ -133,7 +133,8 @@ def source_gate(source, revision, version): code = module.validate_versions(lambda p: (source / p).read_text(), version) except (ValueError, ET.ParseError, OSError): raise ReleaseError('Release metadata does not match the requested version.') from None - require(code >= 6 if version == '0.3.1' else code > 0, 'Android version code is too old.') + minimum_code = {'0.3.1': 6, '0.3.2': 7}.get(version, 1) + require(code >= minimum_code, 'Android version code is too old.') recipe = (source / 'snap/snapcraft.yaml').read_text() for field, value in {'name': 'busymax', 'base': 'core24', 'grade': 'devel', 'confinement': 'strict'}.items(): require(re.search(rf'^{field}: {value}$', recipe, re.M), 'Canonical Snap metadata changed unexpectedly.') diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 4ea90c36..79f3959b 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -63,13 +63,13 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) #define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 0,3,1,0 +#define VERSION_AS_NUMBER 0,3,2,0 #endif #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "0.3.1" +#define VERSION_AS_STRING "0.3.2" #endif VS_VERSION_INFO VERSIONINFO